Revert "Revert "refactor: move xmodule folder to root""
This commit is contained in:
committed by
GitHub
parent
46d848be41
commit
a389a9ff10
59
xmodule/js/src/video/00_async_process.js
Normal file
59
xmodule/js/src/video/00_async_process.js
Normal file
@@ -0,0 +1,59 @@
|
||||
(function(define) {
|
||||
define(
|
||||
'video/00_async_process.js',
|
||||
[],
|
||||
function() {
|
||||
'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.
|
||||
*/
|
||||
var AsyncProcess = {
|
||||
array: function(list, process) {
|
||||
if (!_.isArray(list)) {
|
||||
return $.Deferred().reject().promise();
|
||||
}
|
||||
|
||||
if (!_.isFunction(process) || !list.length) {
|
||||
return $.Deferred().resolve(list).promise();
|
||||
}
|
||||
|
||||
var MAX_DELAY = 50, // maximum amount of time that js code should be allowed to run continuously
|
||||
dfd = $.Deferred(),
|
||||
result = [],
|
||||
index = 0,
|
||||
len = list.length;
|
||||
|
||||
var getCurrentTime = function() {
|
||||
return (new Date()).getTime();
|
||||
};
|
||||
|
||||
var handler = function() {
|
||||
var 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();
|
||||
}
|
||||
};
|
||||
|
||||
return AsyncProcess;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
|
||||
80
xmodule/js/src/video/00_component.js
Normal file
80
xmodule/js/src/video/00_component.js
Normal file
@@ -0,0 +1,80 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define('video/00_component.js', [],
|
||||
function() {
|
||||
/**
|
||||
* 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}
|
||||
*/
|
||||
var inherit = Object.create || (function() {
|
||||
var 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}
|
||||
*/
|
||||
var Component = function() {
|
||||
if ($.isFunction(this.initialize)) {
|
||||
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) {
|
||||
var Parent = this,
|
||||
Child = function() {
|
||||
if ($.isFunction(this.initialize)) {
|
||||
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;
|
||||
};
|
||||
|
||||
return Component;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
39
xmodule/js/src/video/00_i18n.js
Normal file
39
xmodule/js/src/video/00_i18n.js
Normal file
@@ -0,0 +1,39 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define(
|
||||
'video/00_i18n.js',
|
||||
[],
|
||||
function() {
|
||||
/**
|
||||
* i18n module.
|
||||
* @exports video/00_i18n.js
|
||||
* @return {object}
|
||||
*/
|
||||
|
||||
return {
|
||||
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')
|
||||
};
|
||||
});
|
||||
}(RequireJS.define));
|
||||
90
xmodule/js/src/video/00_iterator.js
Normal file
90
xmodule/js/src/video/00_iterator.js
Normal file
@@ -0,0 +1,90 @@
|
||||
(function(define) {
|
||||
define(
|
||||
'video/00_iterator.js',
|
||||
[],
|
||||
function() {
|
||||
'use strict';
|
||||
/**
|
||||
* Provides convenient way to work with iterable data.
|
||||
* @exports video/00_iterator.js
|
||||
* @constructor
|
||||
* @param {array} list Array to be iterated.
|
||||
*/
|
||||
var 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;
|
||||
}
|
||||
};
|
||||
|
||||
return Iterator;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
|
||||
236
xmodule/js/src/video/00_resizer.js
Normal file
236
xmodule/js/src/video/00_resizer.js
Normal file
@@ -0,0 +1,236 @@
|
||||
(function(requirejs, require, define) {
|
||||
define(
|
||||
'video/00_resizer.js',
|
||||
[],
|
||||
function() {
|
||||
var Resizer = function(params) {
|
||||
var defaults = {
|
||||
container: window,
|
||||
element: null,
|
||||
containerRatio: null,
|
||||
elementRatio: null
|
||||
},
|
||||
callbacksList = [],
|
||||
delta = {
|
||||
height: 0,
|
||||
width: 0
|
||||
},
|
||||
module = {},
|
||||
mode = null,
|
||||
config;
|
||||
|
||||
var 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;
|
||||
};
|
||||
|
||||
var getData = function() {
|
||||
var $container = $(config.container),
|
||||
containerWidth = $container.width() + delta.width,
|
||||
containerHeight = $container.height() + delta.height,
|
||||
containerRatio = config.containerRatio,
|
||||
|
||||
$element = $(config.element),
|
||||
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
|
||||
};
|
||||
};
|
||||
|
||||
var align = function() {
|
||||
var 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;
|
||||
};
|
||||
|
||||
var alignByWidthOnly = function() {
|
||||
var 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;
|
||||
};
|
||||
|
||||
var alignByHeightOnly = function() {
|
||||
var 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;
|
||||
};
|
||||
|
||||
var setMode = function(param) {
|
||||
if (_.isString(param)) {
|
||||
mode = param;
|
||||
align();
|
||||
}
|
||||
|
||||
return module;
|
||||
};
|
||||
|
||||
var setElement = function(element) {
|
||||
config.element = element;
|
||||
|
||||
return module;
|
||||
};
|
||||
|
||||
var addCallback = function(func) {
|
||||
if ($.isFunction(func)) {
|
||||
callbacksList.push(func);
|
||||
} else {
|
||||
console.error('[Video info]: TypeError: Argument is not a function.');
|
||||
}
|
||||
|
||||
return module;
|
||||
};
|
||||
|
||||
var addOnceCallback = function(func) {
|
||||
if ($.isFunction(func)) {
|
||||
var decorator = function() {
|
||||
func();
|
||||
removeCallback(func);
|
||||
};
|
||||
|
||||
addCallback(decorator);
|
||||
} else {
|
||||
console.error('TypeError: Argument is not a function.');
|
||||
}
|
||||
|
||||
return module;
|
||||
};
|
||||
|
||||
var fireCallbacks = function() {
|
||||
$.each(callbacksList, function(index, callback) {
|
||||
callback();
|
||||
});
|
||||
};
|
||||
|
||||
var removeCallbacks = function() {
|
||||
callbacksList.length = 0;
|
||||
|
||||
return module;
|
||||
};
|
||||
|
||||
var removeCallback = function(func) {
|
||||
var index = $.inArray(func, callbacksList);
|
||||
|
||||
if (index !== -1) {
|
||||
return callbacksList.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
var resetDelta = function() {
|
||||
delta.height = delta.width = 0;
|
||||
|
||||
return module;
|
||||
};
|
||||
|
||||
var addDelta = function(value, side) {
|
||||
if (_.isNumber(value) && _.isNumber(delta[side])) {
|
||||
delta[side] += value;
|
||||
}
|
||||
|
||||
return module;
|
||||
};
|
||||
|
||||
var substractDelta = function(value, side) {
|
||||
if (_.isNumber(value) && _.isNumber(delta[side])) {
|
||||
delta[side] -= value;
|
||||
}
|
||||
|
||||
return module;
|
||||
};
|
||||
|
||||
var destroy = function() {
|
||||
var 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
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return Resizer;
|
||||
});
|
||||
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|
||||
116
xmodule/js/src/video/00_sjson.js
Normal file
116
xmodule/js/src/video/00_sjson.js
Normal file
@@ -0,0 +1,116 @@
|
||||
(function(define) {
|
||||
define(
|
||||
'video/00_sjson.js',
|
||||
[],
|
||||
function() {
|
||||
'use strict';
|
||||
|
||||
var Sjson = function(data) {
|
||||
var sjson = {
|
||||
start: data.start.concat(),
|
||||
text: data.text.concat()
|
||||
},
|
||||
module = {};
|
||||
|
||||
var getter = function(propertyName) {
|
||||
return function() {
|
||||
return sjson[propertyName];
|
||||
};
|
||||
};
|
||||
|
||||
var getStartTimes = getter('start');
|
||||
|
||||
var getCaptions = getter('text');
|
||||
|
||||
var size = function() {
|
||||
return sjson.text.length;
|
||||
};
|
||||
|
||||
function search(time, startTime, endTime) {
|
||||
var 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.
|
||||
*/
|
||||
var filteredTimes = [];
|
||||
var filteredCaptions = [];
|
||||
var startTimes = getStartTimes();
|
||||
var 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
|
||||
};
|
||||
};
|
||||
|
||||
return Sjson;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
|
||||
102
xmodule/js/src/video/00_video_storage.js
Normal file
102
xmodule/js/src/video/00_video_storage.js
Normal file
@@ -0,0 +1,102 @@
|
||||
(function(requirejs, require, define) {
|
||||
define(
|
||||
'video/00_video_storage.js',
|
||||
[],
|
||||
function() {
|
||||
'use strict';
|
||||
/**
|
||||
* Provides convenient way to store key value pairs.
|
||||
*
|
||||
* @param {string} namespace Namespace that is used to store data.
|
||||
* @return {object} VideoStorage API.
|
||||
*/
|
||||
var 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.
|
||||
*/
|
||||
var 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.
|
||||
*/
|
||||
var 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.
|
||||
*/
|
||||
var removeItem = function(name, instanceSpecific) {
|
||||
if (instanceSpecific) {
|
||||
delete window[namespace][id][name];
|
||||
} else {
|
||||
delete window[namespace][name];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Empties the storage.
|
||||
*
|
||||
*/
|
||||
var 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
|
||||
};
|
||||
};
|
||||
|
||||
return VideoStorage;
|
||||
});
|
||||
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|
||||
845
xmodule/js/src/video/01_initialize.js
Normal file
845
xmodule/js/src/video/01_initialize.js
Normal file
@@ -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
|
||||
*/
|
||||
|
||||
(function(requirejs, require, define) {
|
||||
define(
|
||||
'video/01_initialize.js',
|
||||
['video/03_video_player.js', 'video/00_i18n.js', 'moment', 'underscore'],
|
||||
function(VideoPlayer, i18n, moment, _) {
|
||||
var moment = moment || window.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.
|
||||
*/
|
||||
var 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 */
|
||||
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 */
|
||||
|
||||
_youtubeApiDeferred = null,
|
||||
_oldOnYouTubeIframeAPIReady;
|
||||
|
||||
Initialize.prototype = methodsDict;
|
||||
|
||||
return 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.
|
||||
var video, onYTApiReady, 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() {
|
||||
window.onYouTubeIframeAPIReady.resolve();
|
||||
};
|
||||
|
||||
window.onYouTubeIframeAPIReady.resolve = _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) {
|
||||
var 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);
|
||||
}
|
||||
|
||||
function _initializeModules(state, i18n) {
|
||||
var dfd = $.Deferred(),
|
||||
modulesList = $.map(state.modules, function(module) {
|
||||
var 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) {
|
||||
var isBoolean = function(value) {
|
||||
var 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) {
|
||||
var 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.
|
||||
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) {
|
||||
var 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);
|
||||
|
||||
var 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) {
|
||||
var 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() {
|
||||
var self = this,
|
||||
metadataXHRs = [];
|
||||
|
||||
this.metadata = {};
|
||||
|
||||
metadataXHRs = _.map(this.videos, function(url, speed) {
|
||||
return self.getVideoMetadata(url, function(data) {
|
||||
if (data.items.length > 0) {
|
||||
var 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]
|
||||
var 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) {
|
||||
var 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) {
|
||||
var currentSpeed = this.isFlashMode() ? this.speed : '1.0';
|
||||
|
||||
return this.videos[speed] ||
|
||||
this.videos[currentSpeed] ||
|
||||
this.videos['1.0'];
|
||||
}
|
||||
|
||||
function getDuration() {
|
||||
try {
|
||||
return moment.duration(this.metadata[this.youtubeId()].duration, moment.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) {
|
||||
var 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() {
|
||||
var 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) {
|
||||
var 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;
|
||||
}
|
||||
});
|
||||
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|
||||
137
xmodule/js/src/video/025_focus_grabber.js
Normal file
137
xmodule/js/src/video/025_focus_grabber.js
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
|
||||
(function(requirejs, require, define) {
|
||||
// FocusGrabber module.
|
||||
define(
|
||||
'video/025_focus_grabber.js',
|
||||
[],
|
||||
function() {
|
||||
return function(state) {
|
||||
var dfd = $.Deferred();
|
||||
|
||||
state.focusGrabber = {};
|
||||
|
||||
_makeFunctionsPublic(state);
|
||||
_renderElements(state);
|
||||
_bindHandlers(state);
|
||||
|
||||
dfd.resolve();
|
||||
return dfd.promise();
|
||||
};
|
||||
|
||||
|
||||
// Private functions.
|
||||
|
||||
function _makeFunctionsPublic(state) {
|
||||
var 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() {
|
||||
var 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();
|
||||
}
|
||||
});
|
||||
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|
||||
145
xmodule/js/src/video/02_html5_hls_video.js
Normal file
145
xmodule/js/src/video/02_html5_hls_video.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/* 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', ['underscore', '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;
|
||||
|
||||
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) {
|
||||
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;
|
||||
|
||||
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;
|
||||
}());
|
||||
|
||||
return HLSVideo;
|
||||
});
|
||||
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|
||||
390
xmodule/js/src/video/02_html5_video.js
Normal file
390
xmodule/js/src/video/02_html5_video.js
Normal file
@@ -0,0 +1,390 @@
|
||||
/* 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.
|
||||
*
|
||||
* @external RequireJS
|
||||
*
|
||||
* @module HTML5Video
|
||||
*/
|
||||
|
||||
(function(requirejs, require, define) {
|
||||
define(
|
||||
'video/02_html5_video.js',
|
||||
['underscore'],
|
||||
function(_) {
|
||||
var HTML5Video = {};
|
||||
|
||||
HTML5Video.Player = (function() {
|
||||
/*
|
||||
* Constructor function for HTML5 Video player.
|
||||
*
|
||||
* @param {String|Object} el A DOM element where the HTML5 player will
|
||||
* be inserted (as returned by jQuery(selector) function), or a
|
||||
* selector string which will be used to select an element. This is a
|
||||
* required parameter.
|
||||
*
|
||||
* @param config - An object whose properties will be used as
|
||||
* configuration options for the HTML5 video player. This is an
|
||||
* optional parameter. In the case if this parameter is missing, or
|
||||
* some of the config object's properties are missing, defaults will be
|
||||
* used. The available options (and their defaults) are as
|
||||
* follows:
|
||||
*
|
||||
* config = {
|
||||
*
|
||||
* videoSources: [], // An array with properties being video
|
||||
* // sources. The property name is the
|
||||
* // video format of the source. Supported
|
||||
* // video formats are: 'mp4', 'webm', and
|
||||
* // 'ogg'.
|
||||
* 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) {
|
||||
var errorMessage, lastSource, sourceList;
|
||||
|
||||
// Create HTML markup for individual sources of the HTML5 <video> element.
|
||||
sourceList = $.map(config.videoSources, function(source) {
|
||||
return [
|
||||
'<source ',
|
||||
'src="', source,
|
||||
// Following hack allows to open the same video twice
|
||||
// https://code.google.com/p/chromium/issues/detail?id=31014
|
||||
// Check whether the url already has a '?' inside, and if so,
|
||||
// use '&' instead of '?' to prevent breaking the url's integrity.
|
||||
(source.indexOf('?') === -1 ? '?' : '&'),
|
||||
(new Date()).getTime(), '" />'
|
||||
].join('');
|
||||
});
|
||||
|
||||
// do common initialization independent of player type
|
||||
this.init(el, config);
|
||||
|
||||
// Create HTML markup for the <video> element, populating it with
|
||||
// sources from previous step. Set playback not supported error message.
|
||||
errorMessage = [
|
||||
gettext('This browser cannot play .mp4, .ogg, or .webm files.'),
|
||||
gettext('Try using a different browser, such as Google Chrome.')
|
||||
].join('');
|
||||
this.video.innerHTML = sourceList.join('') + errorMessage;
|
||||
|
||||
lastSource = this.videoEl.find('source').last();
|
||||
lastSource.on('error', this.showErrorMessage.bind(this));
|
||||
lastSource.on('error', this.onError.bind(this));
|
||||
this.videoEl.on('error', this.onError.bind(this));
|
||||
}
|
||||
|
||||
Player.prototype.showPlayButton = function() {
|
||||
this.videoOverlayEl.removeClass('is-hidden');
|
||||
};
|
||||
|
||||
Player.prototype.hidePlayButton = function() {
|
||||
this.videoOverlayEl.addClass('is-hidden');
|
||||
};
|
||||
|
||||
Player.prototype.showLoading = function() {
|
||||
this.el
|
||||
.removeClass('is-initialized')
|
||||
.find('.spinner')
|
||||
.removeAttr('tabindex')
|
||||
.attr({'aria-hidden': 'false'});
|
||||
};
|
||||
|
||||
Player.prototype.hideLoading = function() {
|
||||
this.el
|
||||
.addClass('is-initialized')
|
||||
.find('.spinner')
|
||||
.attr({'aria-hidden': 'false', tabindex: -1});
|
||||
};
|
||||
|
||||
Player.prototype.updatePlayerLoadingState = function(state) {
|
||||
if (state === 'show') {
|
||||
this.hidePlayButton();
|
||||
this.showLoading();
|
||||
} else if (state === 'hide') {
|
||||
this.hideLoading();
|
||||
}
|
||||
};
|
||||
|
||||
Player.prototype.callStateChangeCallback = function() {
|
||||
if ($.isFunction(this.config.events.onStateChange)) {
|
||||
this.config.events.onStateChange({
|
||||
data: this.playerState
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Player.prototype.pauseVideo = function() {
|
||||
this.video.pause();
|
||||
};
|
||||
|
||||
Player.prototype.seekTo = function(value) {
|
||||
if (
|
||||
typeof value === 'number' &&
|
||||
value <= this.video.duration &&
|
||||
value >= 0
|
||||
) {
|
||||
this.video.currentTime = value;
|
||||
}
|
||||
};
|
||||
|
||||
Player.prototype.setVolume = function(value) {
|
||||
if (typeof value === 'number' && value <= 100 && value >= 0) {
|
||||
this.video.volume = value * 0.01;
|
||||
}
|
||||
};
|
||||
|
||||
Player.prototype.getCurrentTime = function() {
|
||||
return this.video.currentTime;
|
||||
};
|
||||
|
||||
Player.prototype.playVideo = function() {
|
||||
this.video.play();
|
||||
};
|
||||
|
||||
Player.prototype.getPlayerState = function() {
|
||||
return this.playerState;
|
||||
};
|
||||
|
||||
Player.prototype.getVolume = function() {
|
||||
return this.video.volume;
|
||||
};
|
||||
|
||||
Player.prototype.getDuration = function() {
|
||||
if (isNaN(this.video.duration)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.video.duration;
|
||||
};
|
||||
|
||||
Player.prototype.setPlaybackRate = function(value) {
|
||||
var newSpeed;
|
||||
|
||||
newSpeed = parseFloat(value);
|
||||
|
||||
if (isFinite(newSpeed)) {
|
||||
if (this.video.playbackRate !== value) {
|
||||
this.video.playbackRate = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Player.prototype.getAvailablePlaybackRates = function() {
|
||||
return [0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
Player.prototype._getLogs = function() {
|
||||
return this.logs;
|
||||
};
|
||||
|
||||
Player.prototype.showErrorMessage = function(event, css) {
|
||||
var cssSelecter = css || '.video-player .video-error';
|
||||
this.el
|
||||
.find('.video-player div')
|
||||
.addClass('hidden')
|
||||
.end()
|
||||
.find(cssSelecter)
|
||||
.removeClass('is-hidden')
|
||||
.end()
|
||||
.addClass('is-initialized')
|
||||
.find('.spinner')
|
||||
.attr({
|
||||
'aria-hidden': 'true',
|
||||
tabindex: -1
|
||||
});
|
||||
};
|
||||
|
||||
Player.prototype.onError = function() {
|
||||
if ($.isFunction(this.config.events.onError)) {
|
||||
this.config.events.onError();
|
||||
}
|
||||
};
|
||||
|
||||
Player.prototype.destroy = function() {
|
||||
this.video.removeEventListener('loadedmetadata', this.onLoadedMetadata, false);
|
||||
this.video.removeEventListener('play', this.onPlay, false);
|
||||
this.video.removeEventListener('playing', this.onPlaying, false);
|
||||
this.video.removeEventListener('pause', this.onPause, false);
|
||||
this.video.removeEventListener('ended', this.onEnded, false);
|
||||
this.el
|
||||
.find('.video-player div')
|
||||
.removeClass('is-hidden')
|
||||
.end()
|
||||
.find('.video-player .video-error')
|
||||
.addClass('is-hidden')
|
||||
.end()
|
||||
.removeClass('is-initialized')
|
||||
.find('.spinner')
|
||||
.attr({'aria-hidden': 'false'});
|
||||
this.videoEl.off('remove');
|
||||
this.videoEl.remove();
|
||||
};
|
||||
|
||||
Player.prototype.onReady = function() {
|
||||
this.config.events.onReady(null);
|
||||
this.showPlayButton();
|
||||
};
|
||||
|
||||
Player.prototype.onLoadedMetadata = function() {
|
||||
this.playerState = HTML5Video.PlayerState.PAUSED;
|
||||
if ($.isFunction(this.config.events.onReady)) {
|
||||
this.onReady();
|
||||
}
|
||||
};
|
||||
|
||||
Player.prototype.onPlay = function() {
|
||||
this.playerState = HTML5Video.PlayerState.BUFFERING;
|
||||
this.callStateChangeCallback();
|
||||
this.videoOverlayEl.addClass('is-hidden');
|
||||
};
|
||||
|
||||
Player.prototype.onPlaying = function() {
|
||||
this.playerState = HTML5Video.PlayerState.PLAYING;
|
||||
this.callStateChangeCallback();
|
||||
this.videoOverlayEl.addClass('is-hidden');
|
||||
};
|
||||
|
||||
Player.prototype.onPause = function() {
|
||||
this.playerState = HTML5Video.PlayerState.PAUSED;
|
||||
this.callStateChangeCallback();
|
||||
this.showPlayButton();
|
||||
};
|
||||
|
||||
Player.prototype.onEnded = function() {
|
||||
this.playerState = HTML5Video.PlayerState.ENDED;
|
||||
this.callStateChangeCallback();
|
||||
};
|
||||
|
||||
Player.prototype.init = function(el, config) {
|
||||
var isTouch = window.onTouchBasedDevice() || '',
|
||||
events = ['loadstart', 'progress', 'suspend', 'abort', 'error',
|
||||
'emptied', 'stalled', 'play', 'pause', 'loadedmetadata',
|
||||
'loadeddata', 'waiting', 'playing', 'canplay', 'canplaythrough',
|
||||
'seeking', 'seeked', 'timeupdate', 'ended', 'ratechange',
|
||||
'durationchange', 'volumechange'
|
||||
],
|
||||
self = this,
|
||||
callback;
|
||||
|
||||
this.config = config;
|
||||
this.logs = [];
|
||||
this.el = $(el);
|
||||
|
||||
// Because of problems with creating video element via jquery
|
||||
// (http://bugs.jquery.com/ticket/9174) we create it using native JS.
|
||||
this.video = document.createElement('video');
|
||||
|
||||
// Get the jQuery object and set error event handlers
|
||||
this.videoEl = $(this.video);
|
||||
|
||||
// Video player overlay play button
|
||||
this.videoOverlayEl = this.el.find('.video-wrapper .btn-play');
|
||||
|
||||
// The player state is used by other parts of the VideoPlayer to
|
||||
// determine what the video is currently doing.
|
||||
this.playerState = HTML5Video.PlayerState.UNSTARTED;
|
||||
|
||||
_.bindAll(this, 'onLoadedMetadata', 'onPlay', 'onPlaying', 'onPause', 'onEnded');
|
||||
|
||||
// Attach a 'click' event on the <video> element. It will cause the
|
||||
// video to pause/play.
|
||||
callback = function() {
|
||||
var PlayerState = HTML5Video.PlayerState;
|
||||
|
||||
if (self.playerState === PlayerState.PLAYING) {
|
||||
self.playerState = PlayerState.PAUSED;
|
||||
self.pauseVideo();
|
||||
} else {
|
||||
self.playerState = PlayerState.PLAYING;
|
||||
self.playVideo();
|
||||
}
|
||||
};
|
||||
this.videoEl.on('click', callback);
|
||||
this.videoOverlayEl.on('click', callback);
|
||||
|
||||
this.debug = false;
|
||||
$.each(events, function(index, eventName) {
|
||||
self.video.addEventListener(eventName, function() {
|
||||
self.logs.push({
|
||||
'event name': eventName,
|
||||
state: self.playerState
|
||||
});
|
||||
|
||||
if (self.debug) {
|
||||
console.log(
|
||||
'event name:', eventName,
|
||||
'state:', self.playerState,
|
||||
'readyState:', self.video.readyState,
|
||||
'networkState:', self.video.networkState
|
||||
);
|
||||
}
|
||||
|
||||
el.trigger('html5:' + eventName, arguments);
|
||||
});
|
||||
});
|
||||
|
||||
// When the <video> tag has been processed by the browser, and it
|
||||
// is ready for playback, notify other parts of the VideoPlayer,
|
||||
// and initially pause the video.
|
||||
this.video.addEventListener('loadedmetadata', this.onLoadedMetadata, false);
|
||||
this.video.addEventListener('play', this.onPlay, false);
|
||||
this.video.addEventListener('playing', this.onPlaying, false);
|
||||
this.video.addEventListener('pause', this.onPause, false);
|
||||
this.video.addEventListener('ended', this.onEnded, false);
|
||||
|
||||
if (/iP(hone|od)/i.test(isTouch[0])) {
|
||||
this.videoEl.prop('controls', true);
|
||||
}
|
||||
|
||||
// Set video poster
|
||||
if (this.config.poster) {
|
||||
this.videoEl.prop('poster', this.config.poster);
|
||||
}
|
||||
|
||||
// Place the <video> element on the page.
|
||||
this.videoEl.appendTo(el.find('.video-player > div:first-child'));
|
||||
};
|
||||
|
||||
return Player;
|
||||
}());
|
||||
|
||||
// The YouTube API presents several constants which describe the player's
|
||||
// state at a given moment. HTML5Video API will copy these constants so
|
||||
// that code which uses both the YouTube API and this API doesn't have to
|
||||
// change.
|
||||
HTML5Video.PlayerState = {
|
||||
UNSTARTED: -1,
|
||||
ENDED: 0,
|
||||
PLAYING: 1,
|
||||
PAUSED: 2,
|
||||
BUFFERING: 3,
|
||||
CUED: 5
|
||||
};
|
||||
|
||||
// HTML5Video object - what this module exports.
|
||||
return HTML5Video;
|
||||
});
|
||||
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|
||||
69
xmodule/js/src/video/035_video_accessible_menu.js
Normal file
69
xmodule/js/src/video/035_video_accessible_menu.js
Normal file
@@ -0,0 +1,69 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
// VideoTranscriptDownloadHandler module.
|
||||
define(
|
||||
'video/035_video_accessible_menu.js', ['underscore'],
|
||||
function(_) {
|
||||
/**
|
||||
* Video Download Transcript control module.
|
||||
* @exports video/035_video_accessible_menu.js
|
||||
* @constructor
|
||||
* @param {jquery Element} element
|
||||
* @param {Object} options
|
||||
*/
|
||||
var VideoTranscriptDownloadHandler = function(element, options) {
|
||||
if (!(this instanceof VideoTranscriptDownloadHandler)) {
|
||||
return new VideoTranscriptDownloadHandler(element, options);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'clickHandler');
|
||||
|
||||
this.container = element;
|
||||
this.options = options || {};
|
||||
|
||||
if (this.container.find('.wrapper-downloads .wrapper-download-transcripts')) {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
VideoTranscriptDownloadHandler.prototype = {
|
||||
// Initializes the module.
|
||||
initialize: function() {
|
||||
this.value = this.options.storage.getItem('transcript_download_format');
|
||||
this.el = this.container.find('.list-download-transcripts');
|
||||
this.el.on('click', '.btn-link', this.clickHandler);
|
||||
},
|
||||
|
||||
// Event handler. We delay link clicks until the file type is set
|
||||
clickHandler: function(event) {
|
||||
var that = this,
|
||||
fileType,
|
||||
data,
|
||||
downloadUrl;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
fileType = $(event.target).data('value');
|
||||
data = {transcript_download_format: fileType};
|
||||
downloadUrl = $(event.target).attr('href');
|
||||
|
||||
$.ajax({
|
||||
url: this.options.saveStateUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: data,
|
||||
success: function() {
|
||||
that.options.storage.setItem('transcript_download_format', fileType);
|
||||
},
|
||||
complete: function() {
|
||||
document.location.href = downloadUrl;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return VideoTranscriptDownloadHandler;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
908
xmodule/js/src/video/03_video_player.js
Normal file
908
xmodule/js/src/video/03_video_player.js
Normal file
@@ -0,0 +1,908 @@
|
||||
/* 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/02_html5_hls_video.js', 'video/00_resizer.js', 'hls', 'underscore', '../time.js'],
|
||||
function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _, Time) {
|
||||
var dfd = $.Deferred(),
|
||||
VideoPlayer = function(state) {
|
||||
state.videoPlayer = {};
|
||||
_makeFunctionsPublic(state);
|
||||
_initialize(state);
|
||||
// No callbacks to DOM events (click, mousemove, etc.).
|
||||
|
||||
return dfd.promise();
|
||||
},
|
||||
/* eslint-disable no-use-before-define */
|
||||
methodsDict = {
|
||||
destroy: destroy,
|
||||
duration: duration,
|
||||
handlePlaybackQualityChange: handlePlaybackQualityChange,
|
||||
|
||||
// Added for finer graded seeking control.
|
||||
// Please see:
|
||||
// https://developers.google.com/youtube/js_api_reference#Events
|
||||
isBuffering: isBuffering,
|
||||
// https://developers.google.com/youtube/js_api_reference#cueVideoById
|
||||
isCued: isCued,
|
||||
|
||||
isEnded: isEnded,
|
||||
isPlaying: isPlaying,
|
||||
isUnstarted: isUnstarted,
|
||||
onCaptionSeek: onSeek,
|
||||
onEnded: onEnded,
|
||||
onError: onError,
|
||||
onPause: onPause,
|
||||
onPlay: onPlay,
|
||||
runTimer: runTimer,
|
||||
stopTimer: stopTimer,
|
||||
onLoadMetadataHtml5: onLoadMetadataHtml5,
|
||||
onPlaybackQualityChange: onPlaybackQualityChange,
|
||||
onReady: onReady,
|
||||
onSlideSeek: onSeek,
|
||||
onSpeedChange: onSpeedChange,
|
||||
onAutoAdvanceChange: onAutoAdvanceChange,
|
||||
onStateChange: onStateChange,
|
||||
onUnstarted: onUnstarted,
|
||||
onVolumeChange: onVolumeChange,
|
||||
pause: pause,
|
||||
play: play,
|
||||
seekTo: seekTo,
|
||||
setPlaybackRate: setPlaybackRate,
|
||||
update: update,
|
||||
figureOutStartEndTime: figureOutStartEndTime,
|
||||
figureOutStartingTime: figureOutStartingTime,
|
||||
updatePlayTime: updatePlayTime
|
||||
};
|
||||
/* eslint-enable no-use-before-define */
|
||||
|
||||
VideoPlayer.prototype = methodsDict;
|
||||
|
||||
// VideoPlayer() function - what this module "exports".
|
||||
return VideoPlayer;
|
||||
|
||||
// ***************************************************************
|
||||
// Private functions start here.
|
||||
// ***************************************************************
|
||||
|
||||
// function _makeFunctionsPublic(state)
|
||||
//
|
||||
// Functions which will be accessible via 'state' object. When called,
|
||||
// these functions will get the 'state' object as a context.
|
||||
function _makeFunctionsPublic(state) {
|
||||
var debouncedF = _.debounce(
|
||||
function(params) {
|
||||
// Can't cancel a queued debounced function on destroy
|
||||
if (state.videoPlayer) {
|
||||
return onSeek.call(this, params);
|
||||
}
|
||||
}.bind(state),
|
||||
300
|
||||
);
|
||||
|
||||
state.bindTo(methodsDict, state.videoPlayer, state);
|
||||
|
||||
state.videoPlayer.onSlideSeek = debouncedF;
|
||||
state.videoPlayer.onCaptionSeek = debouncedF;
|
||||
}
|
||||
|
||||
// Updates players state, once metadata is loaded for html5 player.
|
||||
function onLoadMetadataHtml5() {
|
||||
var player = this.videoPlayer.player.videoEl,
|
||||
videoWidth = player[0].videoWidth || player.width(),
|
||||
videoHeight = player[0].videoHeight || player.height();
|
||||
|
||||
_resize(this, videoWidth, videoHeight);
|
||||
_updateVcrAndRegion(this);
|
||||
}
|
||||
|
||||
|
||||
// function _initialize(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.
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
function _initialize(state) {
|
||||
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
|
||||
// metadata is loaded, which normally happens just after the video
|
||||
// starts playing. Just after that configurations can be applied.
|
||||
state.videoPlayer.ready = _.once(function() {
|
||||
if (!state.isFlashMode() && state.speed != '1.0') {
|
||||
state.videoPlayer.setPlaybackRate(state.speed);
|
||||
}
|
||||
});
|
||||
|
||||
if (state.isYoutubeType()) {
|
||||
state.videoPlayer.PlayerState = YT.PlayerState;
|
||||
state.videoPlayer.PlayerState.UNSTARTED = -1;
|
||||
} else {
|
||||
state.videoPlayer.PlayerState = HTML5Video.PlayerState;
|
||||
}
|
||||
|
||||
state.videoPlayer.currentTime = 0;
|
||||
|
||||
state.videoPlayer.goToStartTime = true;
|
||||
state.videoPlayer.stopAtEndTime = true;
|
||||
|
||||
state.videoPlayer.playerVars = {
|
||||
controls: 0,
|
||||
wmode: 'transparent',
|
||||
rel: 0,
|
||||
showinfo: 0,
|
||||
enablejsapi: 1,
|
||||
modestbranding: 1,
|
||||
cc_load_policy: 0
|
||||
};
|
||||
|
||||
if (!state.isFlashMode()) {
|
||||
state.videoPlayer.playerVars.html5 = 1;
|
||||
}
|
||||
|
||||
// Detect the current browser for several browser-specific work-arounds.
|
||||
userAgent = navigator.userAgent.toLowerCase();
|
||||
state.browserIsFirefox = userAgent.indexOf('firefox') > -1;
|
||||
state.browserIsChrome = userAgent.indexOf('chrome') > -1;
|
||||
// Chrome includes both "Chrome" and "Safari" in the user agent.
|
||||
state.browserIsSafari = (userAgent.indexOf('safari') > -1 &&
|
||||
!state.browserIsChrome);
|
||||
|
||||
// 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,
|
||||
poster: state.config.poster,
|
||||
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, {
|
||||
state: state,
|
||||
onReadyHLS: function() { dfd.resolve(); },
|
||||
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(eventToBeTriggered, state.videoPlayer.onLoadMetadataHtml5, false);
|
||||
player.on('remove', state.videoPlayer.destroy);
|
||||
} else {
|
||||
youTubeId = state.youtubeId();
|
||||
|
||||
state.videoPlayer.player = new YT.Player(state.id, {
|
||||
playerVars: state.videoPlayer.playerVars,
|
||||
videoId: youTubeId,
|
||||
events: {
|
||||
onReady: state.videoPlayer.onReady,
|
||||
onStateChange: state.videoPlayer.onStateChange,
|
||||
onPlaybackQualityChange: state.videoPlayer.onPlaybackQualityChange,
|
||||
onError: state.videoPlayer.onError
|
||||
}
|
||||
});
|
||||
|
||||
state.el.on('initialize', function() {
|
||||
var player = state.videoEl = state.el.find('iframe'),
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
if (state.isTouch) {
|
||||
dfd.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function _updateVcrAndRegion(state, isYoutube) {
|
||||
var update = function(state) {
|
||||
var duration = state.videoPlayer.duration(),
|
||||
time;
|
||||
|
||||
time = state.videoPlayer.figureOutStartingTime(duration);
|
||||
|
||||
// Update the VCR.
|
||||
state.trigger(
|
||||
'videoControl.updateVcrVidTime',
|
||||
{
|
||||
time: time,
|
||||
duration: duration
|
||||
}
|
||||
);
|
||||
|
||||
// Update the time slider.
|
||||
state.trigger(
|
||||
'videoProgressSlider.updateStartEndTimeRegion',
|
||||
{
|
||||
duration: duration
|
||||
}
|
||||
);
|
||||
state.trigger(
|
||||
'videoProgressSlider.updatePlayTime',
|
||||
{
|
||||
time: time,
|
||||
duration: duration
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// After initialization, update the VCR with total time.
|
||||
// At this point only the metadata duration is available (not
|
||||
// very precise), but it is better than having 00:00:00 for
|
||||
// total time.
|
||||
if (state.youtubeMetadataReceived || !isYoutube) {
|
||||
// Metadata was already received, and is available.
|
||||
update(state);
|
||||
} else {
|
||||
// We wait for metadata to arrive, before we request the update
|
||||
// of the VCR video time, and of the start-end time region.
|
||||
// Metadata contains duration of the video.
|
||||
state.el.on('metadata_received', function() {
|
||||
update(state);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _resize(state, videoWidth, videoHeight) {
|
||||
state.resizer = new Resizer({
|
||||
element: state.videoEl,
|
||||
elementRatio: videoWidth / videoHeight,
|
||||
container: state.container
|
||||
})
|
||||
.callbacks.once(function() {
|
||||
state.el.trigger('caption:resize');
|
||||
})
|
||||
.setMode('width');
|
||||
|
||||
// Update captions size when controls becomes visible on iPad or Android
|
||||
if (/iPad|Android/i.test(state.isTouch[0])) {
|
||||
state.el.on('controls:show', function() {
|
||||
state.el.trigger('caption:resize');
|
||||
});
|
||||
}
|
||||
|
||||
$(window).on('resize.video', _.debounce(function() {
|
||||
state.trigger('videoFullScreen.updateControlsHeight', null);
|
||||
state.el.trigger('caption:resize');
|
||||
state.resizer.align();
|
||||
}, 100));
|
||||
}
|
||||
|
||||
// function _restartUsingFlash(state)
|
||||
//
|
||||
// When we are about to play a YouTube video in HTML5 mode and discover
|
||||
// that we only have one available playback rate, we will switch to
|
||||
// Flash mode. In Flash speed switching is done by reloading videos
|
||||
// recorded at different frame rates.
|
||||
function _restartUsingFlash(state) {
|
||||
// Remove from the page current iFrame with HTML5 video.
|
||||
state.videoPlayer.player.destroy();
|
||||
|
||||
state.setPlayerMode('flash');
|
||||
|
||||
console.log('[Video info]: Changing YouTube player mode to "flash".');
|
||||
|
||||
// Removed configuration option that requests the HTML5 mode.
|
||||
delete state.videoPlayer.playerVars.html5;
|
||||
|
||||
// Request for the creation of a new Flash player
|
||||
state.videoPlayer.player = new YT.Player(state.id, {
|
||||
playerVars: state.videoPlayer.playerVars,
|
||||
videoId: state.youtubeId(),
|
||||
events: {
|
||||
onReady: state.videoPlayer.onReady,
|
||||
onStateChange: state.videoPlayer.onStateChange,
|
||||
onPlaybackQualityChange: state.videoPlayer.onPlaybackQualityChange,
|
||||
onError: state.videoPlayer.onError
|
||||
}
|
||||
});
|
||||
|
||||
_updateVcrAndRegion(state, true);
|
||||
state.el.trigger('caption:fetch');
|
||||
state.resizer.setElement(state.el.find('iframe')).align();
|
||||
}
|
||||
|
||||
// ***************************************************************
|
||||
// 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 destroy() {
|
||||
var player = this.videoPlayer.player;
|
||||
this.el.removeClass([
|
||||
'is-unstarted', 'is-playing', 'is-paused', 'is-buffered',
|
||||
'is-ended', 'is-cued'
|
||||
].join(' '));
|
||||
$(window).off('.video');
|
||||
this.el.trigger('destroy');
|
||||
this.el.off();
|
||||
this.videoPlayer.stopTimer();
|
||||
if (this.resizer && this.resizer.destroy) {
|
||||
this.resizer.destroy();
|
||||
}
|
||||
if (player && player.video) {
|
||||
player.video.removeEventListener('loadedmetadata', this.videoPlayer.onLoadMetadataHtml5, false);
|
||||
}
|
||||
if (player && _.isFunction(player.destroy)) {
|
||||
player.destroy();
|
||||
}
|
||||
if (this.canPlayHLS && player.hls) {
|
||||
player.hls.destroy();
|
||||
}
|
||||
delete this.videoPlayer;
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (this.videoPlayer.player.pauseVideo) {
|
||||
this.videoPlayer.player.pauseVideo();
|
||||
}
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (this.videoPlayer.player.playVideo) {
|
||||
if (this.videoPlayer.isEnded()) {
|
||||
// When the video will start playing again from the start, the
|
||||
// start-time and end-time will come back into effect.
|
||||
this.videoPlayer.goToStartTime = true;
|
||||
}
|
||||
|
||||
this.videoPlayer.player.playVideo();
|
||||
}
|
||||
}
|
||||
|
||||
// This function gets the video's current play position in time
|
||||
// (currentTime) and its duration.
|
||||
// It is called at a regular interval when the video is playing.
|
||||
function update(time) {
|
||||
this.videoPlayer.currentTime = time || this.videoPlayer.player.getCurrentTime();
|
||||
|
||||
if (isFinite(this.videoPlayer.currentTime)) {
|
||||
this.videoPlayer.updatePlayTime(this.videoPlayer.currentTime);
|
||||
|
||||
// We need to pause the video if current time is smaller (or equal)
|
||||
// than end-time. Also, we must make sure that this is only done
|
||||
// once per video playing from start to end.
|
||||
if (
|
||||
this.videoPlayer.endTime !== null &&
|
||||
this.videoPlayer.endTime <= this.videoPlayer.currentTime
|
||||
) {
|
||||
this.videoPlayer.pause();
|
||||
|
||||
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
|
||||
end: true
|
||||
});
|
||||
|
||||
this.el.trigger('stop');
|
||||
}
|
||||
this.el.trigger('timeupdate', [this.videoPlayer.currentTime]);
|
||||
}
|
||||
}
|
||||
|
||||
function setPlaybackRate(newSpeed) {
|
||||
this.videoPlayer.player.setPlaybackRate(newSpeed);
|
||||
}
|
||||
|
||||
function onSpeedChange(newSpeed) {
|
||||
var time = this.videoPlayer.currentTime;
|
||||
|
||||
if (this.isFlashMode()) {
|
||||
this.videoPlayer.currentTime = Time.convert(
|
||||
time,
|
||||
parseFloat(this.speed),
|
||||
newSpeed
|
||||
);
|
||||
}
|
||||
|
||||
newSpeed = parseFloat(newSpeed);
|
||||
this.setSpeed(newSpeed);
|
||||
this.videoPlayer.setPlaybackRate(newSpeed);
|
||||
}
|
||||
|
||||
function onAutoAdvanceChange(enabled) {
|
||||
this.setAutoAdvance(enabled);
|
||||
}
|
||||
|
||||
// Every 200 ms, if the video is playing, we call the function update, via
|
||||
// clearInterval. This interval is called updateInterval.
|
||||
// It is created on a onPlay event. Cleared on a onPause event.
|
||||
// Reinitialized on a onSeek event.
|
||||
function onSeek(params) {
|
||||
var time = params.time,
|
||||
type = params.type,
|
||||
oldTime = this.videoPlayer.currentTime;
|
||||
// After the user seeks, the video will start playing from
|
||||
// the sought point, and stop playing at the end.
|
||||
this.videoPlayer.goToStartTime = false;
|
||||
|
||||
this.videoPlayer.seekTo(time);
|
||||
|
||||
this.el.trigger('seek', [time, oldTime, type]);
|
||||
}
|
||||
|
||||
function seekTo(time) {
|
||||
var duration = this.videoPlayer.duration();
|
||||
|
||||
if ((typeof time !== 'number') || (time > duration) || (time < 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.el.off('play.seek');
|
||||
|
||||
if (this.videoPlayer.isPlaying()) {
|
||||
this.videoPlayer.stopTimer();
|
||||
}
|
||||
var isUnplayed = this.videoPlayer.isUnstarted() ||
|
||||
this.videoPlayer.isCued();
|
||||
|
||||
// Use `cueVideoById` method for youtube video that is not played before.
|
||||
if (isUnplayed && this.isYoutubeType()) {
|
||||
this.videoPlayer.player.cueVideoById(this.youtubeId(), time);
|
||||
} else {
|
||||
// Youtube video cannot be rewinded during bufferization, so wait to
|
||||
// finish bufferization and then rewind the video.
|
||||
if (this.isYoutubeType() && this.videoPlayer.isBuffering()) {
|
||||
this.el.on('play.seek', function() {
|
||||
this.videoPlayer.player.seekTo(time, true);
|
||||
}.bind(this));
|
||||
} else {
|
||||
// Otherwise, just seek the video
|
||||
this.videoPlayer.player.seekTo(time, true);
|
||||
}
|
||||
}
|
||||
|
||||
this.videoPlayer.updatePlayTime(time, true);
|
||||
|
||||
// the timer is stopped above; restart it.
|
||||
if (this.videoPlayer.isPlaying()) {
|
||||
this.videoPlayer.runTimer();
|
||||
}
|
||||
// Update the the current time when user seek. (YoutubePlayer)
|
||||
this.videoPlayer.currentTime = time;
|
||||
}
|
||||
|
||||
function runTimer() {
|
||||
if (!this.videoPlayer.updateInterval) {
|
||||
this.videoPlayer.updateInterval = window.setInterval(
|
||||
this.videoPlayer.update, 200
|
||||
);
|
||||
|
||||
this.videoPlayer.update();
|
||||
}
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
window.clearInterval(this.videoPlayer.updateInterval);
|
||||
delete this.videoPlayer.updateInterval;
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
var time = this.videoPlayer.duration();
|
||||
|
||||
|
||||
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
|
||||
end: true
|
||||
});
|
||||
|
||||
if (this.videoPlayer.skipOnEndedStartEndReset) {
|
||||
this.videoPlayer.skipOnEndedStartEndReset = undefined;
|
||||
}
|
||||
// Sometimes `onEnded` events fires when `currentTime` not equal
|
||||
// `duration`. In this case, slider doesn't reach the end point of
|
||||
// timeline.
|
||||
this.videoPlayer.updatePlayTime(time);
|
||||
|
||||
// Emit 'pause_video' event when a video ends if Player is of Youtube
|
||||
if (this.isYoutubeType()) {
|
||||
this.el.trigger('pause', arguments);
|
||||
}
|
||||
this.el.trigger('ended', arguments);
|
||||
}
|
||||
|
||||
function onPause() {
|
||||
this.videoPlayer.stopTimer();
|
||||
this.el.trigger('pause', arguments);
|
||||
}
|
||||
|
||||
function onPlay() {
|
||||
this.videoPlayer.runTimer();
|
||||
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
|
||||
end: false
|
||||
});
|
||||
this.videoPlayer.ready();
|
||||
this.el.trigger('play', arguments);
|
||||
}
|
||||
|
||||
function onUnstarted() { }
|
||||
|
||||
function handlePlaybackQualityChange(value) {
|
||||
this.videoPlayer.player.setPlaybackQuality(value);
|
||||
}
|
||||
|
||||
function onPlaybackQualityChange() {
|
||||
var quality;
|
||||
|
||||
quality = this.videoPlayer.player.getPlaybackQuality();
|
||||
|
||||
this.trigger('videoQualityControl.onQualityChange', quality);
|
||||
this.el.trigger('qualitychange', arguments);
|
||||
}
|
||||
|
||||
function onReady() {
|
||||
var _this = this,
|
||||
availablePlaybackRates, baseSpeedSubs,
|
||||
player, videoWidth, videoHeight;
|
||||
|
||||
dfd.resolve();
|
||||
|
||||
this.el.on('speedchange', function(event, speed) {
|
||||
_this.videoPlayer.onSpeedChange(speed);
|
||||
});
|
||||
|
||||
this.el.on('autoadvancechange', function(event, enabled) {
|
||||
_this.videoPlayer.onAutoAdvanceChange(enabled);
|
||||
});
|
||||
|
||||
this.el.on('volumechange volumechange:silent', function(event, volume) {
|
||||
_this.videoPlayer.onVolumeChange(volume);
|
||||
});
|
||||
|
||||
availablePlaybackRates = this.videoPlayer.player
|
||||
.getAvailablePlaybackRates();
|
||||
|
||||
// Because of problems with muting sound outside of range 0.25 and
|
||||
// 5.0, we should filter our available playback rates.
|
||||
// Issues:
|
||||
// https://code.google.com/p/chromium/issues/detail?id=264341
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=840745
|
||||
// https://developer.mozilla.org/en-US/docs/DOM/HTMLMediaElement
|
||||
|
||||
availablePlaybackRates = _.filter(
|
||||
availablePlaybackRates,
|
||||
function(item) {
|
||||
var speed = Number(item);
|
||||
return speed > 0.25 && speed <= 5;
|
||||
}
|
||||
);
|
||||
|
||||
// Because of a recent change in the YouTube API (not documented), sometimes
|
||||
// HTML5 mode loads after Flash mode has been loaded. In this case we have
|
||||
// multiple speeds available but the variable `this.currentPlayerMode` is
|
||||
// set to "flash". This is impossible because in Flash mode we can have
|
||||
// only one speed available. Therefore we must execute the following code
|
||||
// block if we have multiple speeds or if `this.currentPlayerMode` is set to
|
||||
// "html5". If any of the two conditions are true, we then set the variable
|
||||
// `this.currentPlayerMode` to "html5".
|
||||
//
|
||||
// For more information, please see the PR that introduced this change:
|
||||
// https://github.com/edx/edx-platform/pull/2841
|
||||
if (
|
||||
(this.isHtml5Mode() || availablePlaybackRates.length > 1) &&
|
||||
this.isYoutubeType()
|
||||
) {
|
||||
if (availablePlaybackRates.length === 1 && !this.isTouch) {
|
||||
// This condition is needed in cases when Firefox version is
|
||||
// less than 20. In those versions HTML5 playback could only
|
||||
// happen at 1 speed (no speed changing). Therefore, in this
|
||||
// case, we need to switch back to Flash.
|
||||
//
|
||||
// This might also happen in other browsers, therefore when we
|
||||
// have 1 speed available, we fall back to Flash.
|
||||
|
||||
_restartUsingFlash(this);
|
||||
return false;
|
||||
} else if (availablePlaybackRates.length > 1) {
|
||||
this.setPlayerMode('html5');
|
||||
|
||||
// We need to synchronize available frame rates with the ones
|
||||
// that the user specified.
|
||||
|
||||
baseSpeedSubs = this.videos['1.0'];
|
||||
// this.videos is a dictionary containing various frame rates
|
||||
// and their associated subs.
|
||||
|
||||
// First clear the dictionary.
|
||||
$.each(this.videos, function(index, value) {
|
||||
delete _this.videos[index];
|
||||
});
|
||||
this.speeds = [];
|
||||
// Recreate it with the supplied frame rates.
|
||||
$.each(availablePlaybackRates, function(index, value) {
|
||||
var key = value.toFixed(2).replace(/\.00$/, '.0');
|
||||
|
||||
_this.videos[key] = baseSpeedSubs;
|
||||
_this.speeds.push(key);
|
||||
});
|
||||
|
||||
this.setSpeed(this.speed);
|
||||
this.el.trigger('speed:render', [this.speeds, this.speed]);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isFlashMode()) {
|
||||
this.setSpeed(this.speed);
|
||||
this.el.trigger('speed:set', [this.speed]);
|
||||
}
|
||||
|
||||
if (this.isHtml5Mode()) {
|
||||
this.videoPlayer.player.setPlaybackRate(this.speed);
|
||||
}
|
||||
|
||||
|
||||
var duration = this.videoPlayer.duration(),
|
||||
time = this.videoPlayer.figureOutStartingTime(duration);
|
||||
|
||||
// this.duration will be set initially only if duration is coming from edx-val
|
||||
this.duration = this.duration || duration;
|
||||
|
||||
if (time > 0 && this.videoPlayer.goToStartTime) {
|
||||
this.videoPlayer.seekTo(time);
|
||||
}
|
||||
|
||||
this.el.trigger('ready', arguments);
|
||||
|
||||
if (this.config.autoplay) {
|
||||
this.videoPlayer.play();
|
||||
}
|
||||
}
|
||||
|
||||
function onStateChange(event) {
|
||||
this.el.removeClass([
|
||||
'is-unstarted', 'is-playing', 'is-paused', 'is-buffered',
|
||||
'is-ended', 'is-cued'
|
||||
].join(' '));
|
||||
|
||||
switch (event.data) {
|
||||
case this.videoPlayer.PlayerState.UNSTARTED:
|
||||
this.el.addClass('is-unstarted');
|
||||
this.videoPlayer.onUnstarted();
|
||||
break;
|
||||
case this.videoPlayer.PlayerState.PLAYING:
|
||||
this.el.addClass('is-playing');
|
||||
this.videoPlayer.onPlay();
|
||||
break;
|
||||
case this.videoPlayer.PlayerState.PAUSED:
|
||||
this.el.addClass('is-paused');
|
||||
this.videoPlayer.onPause();
|
||||
break;
|
||||
case this.videoPlayer.PlayerState.BUFFERING:
|
||||
this.el.addClass('is-buffered');
|
||||
this.el.trigger('buffering');
|
||||
break;
|
||||
case this.videoPlayer.PlayerState.ENDED:
|
||||
this.el.addClass('is-ended');
|
||||
this.videoPlayer.onEnded();
|
||||
break;
|
||||
case this.videoPlayer.PlayerState.CUED:
|
||||
this.el.addClass('is-cued');
|
||||
if (this.isFlashMode()) {
|
||||
this.videoPlayer.play();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onError(code) {
|
||||
this.el.trigger('error', [code]);
|
||||
}
|
||||
|
||||
function figureOutStartEndTime(duration) {
|
||||
var videoPlayer = this.videoPlayer;
|
||||
|
||||
videoPlayer.startTime = this.config.startTime;
|
||||
if (videoPlayer.startTime >= duration) {
|
||||
videoPlayer.startTime = 0;
|
||||
} else if (this.isFlashMode()) {
|
||||
videoPlayer.startTime /= Number(this.speed);
|
||||
}
|
||||
|
||||
videoPlayer.endTime = this.config.endTime;
|
||||
if (
|
||||
videoPlayer.endTime <= videoPlayer.startTime ||
|
||||
videoPlayer.endTime >= duration
|
||||
) {
|
||||
videoPlayer.endTime = null;
|
||||
} else if (this.isFlashMode()) {
|
||||
videoPlayer.endTime /= Number(this.speed);
|
||||
}
|
||||
}
|
||||
|
||||
function figureOutStartingTime(duration) {
|
||||
var savedVideoPosition = this.config.savedVideoPosition,
|
||||
|
||||
// Default starting time is 0. This is the case when
|
||||
// there is not start-time, no previously saved position,
|
||||
// or one (or both) of those values is incorrect.
|
||||
time = 0,
|
||||
|
||||
startTime, endTime;
|
||||
|
||||
this.videoPlayer.figureOutStartEndTime(duration);
|
||||
|
||||
startTime = this.videoPlayer.startTime;
|
||||
endTime = this.videoPlayer.endTime;
|
||||
|
||||
if (startTime > 0) {
|
||||
if (
|
||||
startTime < savedVideoPosition &&
|
||||
(endTime > savedVideoPosition || endTime === null) &&
|
||||
|
||||
// We do not want to jump to the end of the video.
|
||||
// We subtract 1 from the duration for a 1 second
|
||||
// safety net.
|
||||
savedVideoPosition < duration - 1
|
||||
) {
|
||||
time = savedVideoPosition;
|
||||
} else {
|
||||
time = startTime;
|
||||
}
|
||||
} else if (
|
||||
savedVideoPosition > 0 &&
|
||||
(endTime > savedVideoPosition || endTime === null) &&
|
||||
|
||||
// We do not want to jump to the end of the video.
|
||||
// We subtract 1 from the duration for a 1 second
|
||||
// safety net.
|
||||
savedVideoPosition < duration - 1
|
||||
) {
|
||||
time = savedVideoPosition;
|
||||
}
|
||||
|
||||
return time;
|
||||
}
|
||||
|
||||
function updatePlayTime(time, skip_seek) {
|
||||
var videoPlayer = this.videoPlayer,
|
||||
endTime = this.videoPlayer.duration(),
|
||||
youTubeId;
|
||||
|
||||
if (this.config.endTime) {
|
||||
endTime = Math.min(this.config.endTime, endTime);
|
||||
}
|
||||
|
||||
this.trigger(
|
||||
'videoProgressSlider.updatePlayTime',
|
||||
{
|
||||
time: time,
|
||||
duration: endTime
|
||||
}
|
||||
);
|
||||
|
||||
this.trigger(
|
||||
'videoControl.updateVcrVidTime',
|
||||
{
|
||||
time: time,
|
||||
duration: endTime
|
||||
}
|
||||
);
|
||||
|
||||
this.el.trigger('caption:update', [time]);
|
||||
}
|
||||
|
||||
function isEnded() {
|
||||
var playerState = this.videoPlayer.player.getPlayerState(),
|
||||
ENDED = this.videoPlayer.PlayerState.ENDED;
|
||||
|
||||
return playerState === ENDED;
|
||||
}
|
||||
|
||||
function isPlaying() {
|
||||
var playerState = this.videoPlayer.player.getPlayerState();
|
||||
|
||||
return playerState === this.videoPlayer.PlayerState.PLAYING;
|
||||
}
|
||||
|
||||
function isBuffering() {
|
||||
var playerState = this.videoPlayer.player.getPlayerState();
|
||||
|
||||
return playerState === this.videoPlayer.PlayerState.BUFFERING;
|
||||
}
|
||||
|
||||
function isCued() {
|
||||
var playerState = this.videoPlayer.player.getPlayerState();
|
||||
|
||||
return playerState === this.videoPlayer.PlayerState.CUED;
|
||||
}
|
||||
|
||||
function isUnstarted() {
|
||||
var playerState = this.videoPlayer.player.getPlayerState();
|
||||
|
||||
return playerState === this.videoPlayer.PlayerState.UNSTARTED;
|
||||
}
|
||||
|
||||
/*
|
||||
* Return the duration of the video in seconds.
|
||||
*
|
||||
* First, try to use the native player API call to get the duration.
|
||||
* If the value returned by the native function is not valid, resort to
|
||||
* the value stored in the metadata for the video. Note that the metadata
|
||||
* is available only for YouTube videos.
|
||||
*
|
||||
* IMPORTANT! It has been observed that sometimes, after initial playback
|
||||
* of the video, when operations "pause" and "play" are performed (in that
|
||||
* sequence), the function will start returning a slightly different value.
|
||||
*
|
||||
* For example: While playing for the first time, the function returns 31.
|
||||
* After pausing the video and then resuming once more, the function will
|
||||
* start returning 31.950656.
|
||||
*
|
||||
* This instability is internal to the player API (or browser internals).
|
||||
*/
|
||||
function duration() {
|
||||
var dur;
|
||||
|
||||
// Sometimes the YouTube API doesn't finish instantiating all of it's
|
||||
// methods, but the execution point arrives here.
|
||||
//
|
||||
// This happens when you have start-time and end-time set, and click "Edit"
|
||||
// in Studio, and then "Save". The Video editor dialog closes, the
|
||||
// video reloads, but the start-end range is not visible.
|
||||
if (this.videoPlayer.player.getDuration) {
|
||||
dur = this.videoPlayer.player.getDuration();
|
||||
}
|
||||
|
||||
// For YouTube videos, before the video starts playing, the API
|
||||
// function player.getDuration() will return 0. This means that the VCR
|
||||
// will show total time as 0 when the page just loads (before the user
|
||||
// clicks the Play button).
|
||||
//
|
||||
// We can do betterin a case when dur is 0 (or less than 0). We can ask
|
||||
// the getDuration() function for total time, which will query the
|
||||
// metadata for a duration.
|
||||
//
|
||||
// Be careful! Often the metadata duration is not very precise. It
|
||||
// might differ by one or two seconds against the actual time as will
|
||||
// be reported later on by the player.getDuration() API function.
|
||||
if (!isFinite(dur) || dur <= 0) {
|
||||
if (this.isYoutubeType()) {
|
||||
dur = this.getDuration();
|
||||
}
|
||||
}
|
||||
|
||||
// Just in case the metadata is garbled, or something went wrong, we
|
||||
// have a final check.
|
||||
if (!isFinite(dur) || dur <= 0) {
|
||||
dur = 0;
|
||||
}
|
||||
|
||||
return Math.floor(dur);
|
||||
}
|
||||
|
||||
function onVolumeChange(volume) {
|
||||
this.videoPlayer.player.setVolume(volume);
|
||||
}
|
||||
});
|
||||
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|
||||
161
xmodule/js/src/video/04_video_control.js
Normal file
161
xmodule/js/src/video/04_video_control.js
Normal file
@@ -0,0 +1,161 @@
|
||||
(function(requirejs, require, define) {
|
||||
'use strict';
|
||||
// VideoControl module.
|
||||
define(
|
||||
'video/04_video_control.js',
|
||||
['time.js'],
|
||||
function(Time) {
|
||||
// VideoControl() function - what this module "exports".
|
||||
return function(state) {
|
||||
var dfd = $.Deferred();
|
||||
|
||||
state.videoControl = {};
|
||||
|
||||
_makeFunctionsPublic(state);
|
||||
_renderElements(state);
|
||||
_bindHandlers(state);
|
||||
|
||||
dfd.resolve();
|
||||
return dfd.promise();
|
||||
};
|
||||
|
||||
// ***************************************************************
|
||||
// Private functions start here.
|
||||
// ***************************************************************
|
||||
|
||||
// function _makeFunctionsPublic(state)
|
||||
//
|
||||
// Functions which will be accessible via 'state' object. When called, these functions will
|
||||
// get the 'state' object as a context.
|
||||
function _makeFunctionsPublic(state) {
|
||||
var methodsDict = {
|
||||
destroy: destroy,
|
||||
hideControls: hideControls,
|
||||
show: show,
|
||||
showControls: showControls,
|
||||
focusFirst: focusFirst,
|
||||
updateVcrVidTime: updateVcrVidTime
|
||||
};
|
||||
|
||||
state.bindTo(methodsDict, state.videoControl, state);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
this.el.off({
|
||||
mousemove: this.videoControl.showControls,
|
||||
keydown: this.videoControl.showControls,
|
||||
destroy: this.videoControl.destroy,
|
||||
initialize: this.videoControl.focusFirst
|
||||
});
|
||||
|
||||
this.el.off('controls:show');
|
||||
if (this.controlHideTimeout) {
|
||||
clearTimeout(this.controlHideTimeout);
|
||||
}
|
||||
delete this.videoControl;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
state.videoControl.el = state.el.find('.video-controls');
|
||||
state.videoControl.vidTimeEl = state.videoControl.el.find('.vidtime');
|
||||
|
||||
if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
|
||||
state.videoControl.fadeOutTimeout = state.config.fadeOutTimeout;
|
||||
|
||||
state.videoControl.el.addClass('html5');
|
||||
state.controlHideTimeout = setTimeout(state.videoControl.hideControls, state.videoControl.fadeOutTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
// function _bindHandlers(state)
|
||||
//
|
||||
// Bind any necessary function callbacks to DOM events (click, mousemove, etc.).
|
||||
function _bindHandlers(state) {
|
||||
if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
|
||||
state.el.on({
|
||||
mousemove: state.videoControl.showControls,
|
||||
keydown: state.videoControl.showControls
|
||||
});
|
||||
}
|
||||
|
||||
if (state.config.focusFirstControl) {
|
||||
state.el.on('initialize', state.videoControl.focusFirst);
|
||||
}
|
||||
state.el.on('destroy', state.videoControl.destroy);
|
||||
}
|
||||
|
||||
// ***************************************************************
|
||||
// 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 focusFirst() {
|
||||
this.videoControl.el.find('.vcr a, .vcr button').first().focus();
|
||||
}
|
||||
|
||||
function show() {
|
||||
this.videoControl.el.removeClass('is-hidden');
|
||||
this.el.trigger('controls:show', arguments);
|
||||
}
|
||||
|
||||
function showControls(event) {
|
||||
if (!this.controlShowLock) {
|
||||
if (!this.captionsHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.controlShowLock = true;
|
||||
|
||||
if (this.controlState === 'invisible') {
|
||||
this.videoControl.el.show();
|
||||
this.controlState = 'visible';
|
||||
} else if (this.controlState === 'hiding') {
|
||||
this.videoControl.el.stop(true, false).css('opacity', 1).show();
|
||||
this.controlState = 'visible';
|
||||
} else if (this.controlState === 'visible') {
|
||||
clearTimeout(this.controlHideTimeout);
|
||||
}
|
||||
|
||||
this.controlHideTimeout = setTimeout(this.videoControl.hideControls, this.videoControl.fadeOutTimeout);
|
||||
this.controlShowLock = false;
|
||||
}
|
||||
}
|
||||
|
||||
function hideControls() {
|
||||
var _this = this;
|
||||
|
||||
this.controlHideTimeout = null;
|
||||
|
||||
if (!this.captionsHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.controlState = 'hiding';
|
||||
this.videoControl.el.fadeOut(this.videoControl.fadeOutTimeout, function() {
|
||||
_this.controlState = 'invisible';
|
||||
// If the focus was on the video control or the volume control,
|
||||
// then we must make sure to close these dialogs. Otherwise, after
|
||||
// next autofocus, these dialogs will be open, but the focus will
|
||||
// not be on them.
|
||||
_this.videoVolumeControl.el.removeClass('open');
|
||||
_this.videoSpeedControl.el.removeClass('open');
|
||||
|
||||
_this.focusGrabber.enableFocusGrabber();
|
||||
});
|
||||
}
|
||||
|
||||
function updateVcrVidTime(params) {
|
||||
var endTime = (this.config.endTime !== null) ? this.config.endTime : params.duration;
|
||||
// in case endTime is accidentally specified as being greater than the video
|
||||
endTime = Math.min(endTime, params.duration);
|
||||
this.videoControl.vidTimeEl.text(Time.format(params.time) + ' / ' + Time.format(endTime));
|
||||
}
|
||||
}
|
||||
);
|
||||
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|
||||
291
xmodule/js/src/video/04_video_full_screen.js
Normal file
291
xmodule/js/src/video/04_video_full_screen.js
Normal file
@@ -0,0 +1,291 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define('video/04_video_full_screen.js', ['edx-ui-toolkit/js/utils/html-utils'], function(HtmlUtils) {
|
||||
var template = [
|
||||
'<button class="control add-fullscreen" aria-disabled="false" title="',
|
||||
gettext('Fill browser'),
|
||||
'" aria-label="',
|
||||
gettext('Fill browser'),
|
||||
'">',
|
||||
'<span class="icon fa fa-arrows-alt" aria-hidden="true"></span>',
|
||||
'</button>'
|
||||
].join('');
|
||||
|
||||
// The following properties and functions enable cross-browser use of the
|
||||
// the Fullscreen Web API.
|
||||
//
|
||||
// function getVendorPrefixed(property)
|
||||
// function getFullscreenElement()
|
||||
// function exitFullscreen()
|
||||
// function requestFullscreen(element, options)
|
||||
//
|
||||
// For more information about the Fullscreen Web API see MDN:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API
|
||||
var prefixedFullscreenProperties = (function() {
|
||||
if ('fullscreenEnabled' in document) {
|
||||
return {
|
||||
fullscreenElement: 'fullscreenElement',
|
||||
fullscreenEnabled: 'fullscreenEnabled',
|
||||
requestFullscreen: 'requestFullscreen',
|
||||
exitFullscreen: 'exitFullscreen',
|
||||
fullscreenchange: 'fullscreenchange',
|
||||
fullscreenerror: 'fullscreenerror'
|
||||
};
|
||||
}
|
||||
if ('webkitFullscreenEnabled' in document) {
|
||||
return {
|
||||
fullscreenElement: 'webkitFullscreenElement',
|
||||
fullscreenEnabled: 'webkitFullscreenEnabled',
|
||||
requestFullscreen: 'webkitRequestFullscreen',
|
||||
exitFullscreen: 'webkitExitFullscreen',
|
||||
fullscreenchange: 'webkitfullscreenchange',
|
||||
fullscreenerror: 'webkitfullscreenerror'
|
||||
};
|
||||
}
|
||||
if ('mozFullScreenEnabled' in document) {
|
||||
return {
|
||||
fullscreenElement: 'mozFullScreenElement',
|
||||
fullscreenEnabled: 'mozFullScreenEnabled',
|
||||
requestFullscreen: 'mozRequestFullScreen',
|
||||
exitFullscreen: 'mozCancelFullScreen',
|
||||
fullscreenchange: 'mozfullscreenchange',
|
||||
fullscreenerror: 'mozfullscreenerror'
|
||||
};
|
||||
}
|
||||
if ('msFullscreenEnabled' in document) {
|
||||
return {
|
||||
fullscreenElement: 'msFullscreenElement',
|
||||
fullscreenEnabled: 'msFullscreenEnabled',
|
||||
requestFullscreen: 'msRequestFullscreen',
|
||||
exitFullscreen: 'msExitFullscreen',
|
||||
fullscreenchange: 'MSFullscreenChange',
|
||||
fullscreenerror: 'MSFullscreenError'
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}());
|
||||
|
||||
function getVendorPrefixed(property) {
|
||||
return prefixedFullscreenProperties[property];
|
||||
}
|
||||
|
||||
function getFullscreenElement() {
|
||||
return document[getVendorPrefixed('fullscreenElement')];
|
||||
}
|
||||
|
||||
function exitFullscreen() {
|
||||
if (document[getVendorPrefixed('exitFullscreen')]) {
|
||||
return document[getVendorPrefixed('exitFullscreen')]();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function requestFullscreen(element, options) {
|
||||
if (element[getVendorPrefixed('requestFullscreen')]) {
|
||||
return element[getVendorPrefixed('requestFullscreen')](options);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ***************************************************************
|
||||
// Private functions start here.
|
||||
// ***************************************************************
|
||||
|
||||
function destroy() {
|
||||
$(document).off('keyup', this.videoFullScreen.exitHandler);
|
||||
this.videoFullScreen.fullScreenEl.remove();
|
||||
this.el.off({
|
||||
destroy: this.videoFullScreen.destroy
|
||||
});
|
||||
document.removeEventListener(
|
||||
getVendorPrefixed('fullscreenchange'),
|
||||
this.videoFullScreen.handleFullscreenChange
|
||||
);
|
||||
if (this.isFullScreen) {
|
||||
this.videoFullScreen.exit();
|
||||
}
|
||||
delete this.videoFullScreen;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
/* eslint-disable no-param-reassign */
|
||||
state.videoFullScreen.fullScreenEl = $(template);
|
||||
state.videoFullScreen.sliderEl = state.el.find('.slider');
|
||||
state.videoFullScreen.fullScreenState = false;
|
||||
HtmlUtils.append(state.el.find('.secondary-controls'), HtmlUtils.HTML(state.videoFullScreen.fullScreenEl));
|
||||
state.videoFullScreen.updateControlsHeight();
|
||||
/* eslint-enable no-param-reassign */
|
||||
}
|
||||
|
||||
// function bindHandlers(state)
|
||||
//
|
||||
// Bind any necessary function callbacks to DOM events (click, mousemove, etc.).
|
||||
function bindHandlers(state) {
|
||||
state.videoFullScreen.fullScreenEl.on('click', state.videoFullScreen.toggleHandler);
|
||||
state.el.on({
|
||||
destroy: state.videoFullScreen.destroy
|
||||
});
|
||||
$(document).on('keyup', state.videoFullScreen.exitHandler);
|
||||
document.addEventListener(
|
||||
getVendorPrefixed('fullscreenchange'),
|
||||
state.videoFullScreen.handleFullscreenChange
|
||||
);
|
||||
}
|
||||
|
||||
function getControlsHeight(controls, slider) {
|
||||
return controls.height() + 0.5 * slider.height();
|
||||
}
|
||||
|
||||
// ***************************************************************
|
||||
// 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 handleFullscreenChange() {
|
||||
if (getFullscreenElement() !== this.el[0] && this.isFullScreen) {
|
||||
// The video was fullscreen so this event must relate to this video
|
||||
this.videoFullScreen.handleExit();
|
||||
}
|
||||
}
|
||||
|
||||
function updateControlsHeight() {
|
||||
var controls = this.el.find('.video-controls'),
|
||||
slider = this.videoFullScreen.sliderEl;
|
||||
this.videoFullScreen.height = getControlsHeight(controls, slider);
|
||||
return this.videoFullScreen.height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler to toggle fullscreen mode.
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
function toggleHandler(event) {
|
||||
event.preventDefault();
|
||||
this.videoCommands.execute('toggleFullScreen');
|
||||
}
|
||||
|
||||
function handleExit() {
|
||||
var fullScreenClassNameEl = this.el.add(document.documentElement);
|
||||
var closedCaptionsEl = this.el.find('.closed-captions');
|
||||
|
||||
if (this.isFullScreen === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.videoFullScreen.fullScreenState = this.isFullScreen = false;
|
||||
fullScreenClassNameEl.removeClass('video-fullscreen');
|
||||
$(window).scrollTop(this.scrollPos);
|
||||
this.videoFullScreen.fullScreenEl
|
||||
.attr({title: gettext('Fill browser'), 'aria-label': gettext('Fill browser')})
|
||||
.find('.icon')
|
||||
.removeClass('fa-compress')
|
||||
.addClass('fa-arrows-alt');
|
||||
|
||||
$(closedCaptionsEl).css({top: '70%', left: '5%'});
|
||||
if (this.resizer) {
|
||||
this.resizer.delta.reset().setMode('width');
|
||||
}
|
||||
this.el.trigger('fullscreen', [this.isFullScreen]);
|
||||
}
|
||||
|
||||
function handleEnter() {
|
||||
var fullScreenClassNameEl = this.el.add(document.documentElement);
|
||||
var closedCaptionsEl = this.el.find('.closed-captions');
|
||||
|
||||
if (this.isFullScreen === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.videoFullScreen.fullScreenState = this.isFullScreen = true;
|
||||
fullScreenClassNameEl.addClass('video-fullscreen');
|
||||
this.videoFullScreen.fullScreenEl
|
||||
.attr({title: gettext('Exit full browser'), 'aria-label': gettext('Exit full browser')})
|
||||
.find('.icon')
|
||||
.removeClass('fa-arrows-alt')
|
||||
.addClass('fa-compress');
|
||||
|
||||
$(closedCaptionsEl).css({top: '70%', left: '5%'});
|
||||
if (this.resizer) {
|
||||
this.resizer.delta.substract(this.videoFullScreen.updateControlsHeight(), 'height').setMode('both');
|
||||
}
|
||||
this.el.trigger('fullscreen', [this.isFullScreen]);
|
||||
}
|
||||
|
||||
function exit() {
|
||||
if (getFullscreenElement() === this.el[0]) {
|
||||
exitFullscreen();
|
||||
} else {
|
||||
// Else some other element is fullscreen or the fullscreen api does not exist.
|
||||
this.videoFullScreen.handleExit();
|
||||
}
|
||||
}
|
||||
|
||||
function enter() {
|
||||
this.scrollPos = $(window).scrollTop();
|
||||
this.videoFullScreen.handleEnter();
|
||||
requestFullscreen(this.el[0]);
|
||||
}
|
||||
|
||||
/** Toggle fullscreen mode. */
|
||||
function toggle() {
|
||||
if (this.videoFullScreen.fullScreenState) {
|
||||
this.videoFullScreen.exit();
|
||||
} else {
|
||||
this.videoFullScreen.enter();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler to exit from fullscreen mode.
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
function exitHandler(event) {
|
||||
if ((this.isFullScreen) && (event.keyCode === 27)) {
|
||||
event.preventDefault();
|
||||
this.videoCommands.execute('toggleFullScreen');
|
||||
}
|
||||
}
|
||||
|
||||
// function makeFunctionsPublic(state)
|
||||
//
|
||||
// Functions which will be accessible via 'state' object. When called, these functions will
|
||||
// get the 'state' object as a context.
|
||||
function makeFunctionsPublic(state) {
|
||||
var methodsDict = {
|
||||
destroy: destroy,
|
||||
enter: enter,
|
||||
exit: exit,
|
||||
exitHandler: exitHandler,
|
||||
handleExit: handleExit,
|
||||
handleEnter: handleEnter,
|
||||
handleFullscreenChange: handleFullscreenChange,
|
||||
toggle: toggle,
|
||||
toggleHandler: toggleHandler,
|
||||
updateControlsHeight: updateControlsHeight
|
||||
};
|
||||
|
||||
state.bindTo(methodsDict, state.videoFullScreen, state);
|
||||
}
|
||||
|
||||
// VideoControl() function - what this module "exports".
|
||||
return function(state) {
|
||||
var dfd = $.Deferred();
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
state.videoFullScreen = {};
|
||||
|
||||
makeFunctionsPublic(state);
|
||||
renderElements(state);
|
||||
bindHandlers(state);
|
||||
|
||||
dfd.resolve();
|
||||
return dfd.promise();
|
||||
};
|
||||
});
|
||||
}(RequireJS.define));
|
||||
178
xmodule/js/src/video/05_video_quality_control.js
Normal file
178
xmodule/js/src/video/05_video_quality_control.js
Normal file
@@ -0,0 +1,178 @@
|
||||
(function(requirejs, require, define) {
|
||||
// VideoQualityControl module.
|
||||
'use strict';
|
||||
define(
|
||||
'video/05_video_quality_control.js',
|
||||
['edx-ui-toolkit/js/utils/html-utils'],
|
||||
function(HtmlUtils) {
|
||||
var template = HtmlUtils.interpolateHtml(
|
||||
HtmlUtils.HTML([
|
||||
'<button class="control quality-control is-hidden" aria-disabled="false" title="',
|
||||
'{highDefinition}',
|
||||
'">',
|
||||
'<span class="icon icon-hd" aria-hidden="true">HD</span>',
|
||||
'<span class="sr text-translation">',
|
||||
'{highDefinition}',
|
||||
'</span> ',
|
||||
'<span class="sr control-text">',
|
||||
'{off}',
|
||||
'</span>',
|
||||
'</button>'
|
||||
].join('')),
|
||||
{
|
||||
highDefinition: gettext('High Definition'),
|
||||
off: gettext('off')
|
||||
}
|
||||
);
|
||||
|
||||
// VideoQualityControl() function - what this module "exports".
|
||||
return function(state) {
|
||||
var dfd = $.Deferred();
|
||||
|
||||
// Changing quality for now only works for YouTube videos.
|
||||
if (state.videoType !== 'youtube') {
|
||||
return;
|
||||
}
|
||||
|
||||
state.videoQualityControl = {};
|
||||
|
||||
_makeFunctionsPublic(state);
|
||||
_renderElements(state);
|
||||
_bindHandlers(state);
|
||||
|
||||
dfd.resolve();
|
||||
return dfd.promise();
|
||||
};
|
||||
|
||||
// ***************************************************************
|
||||
// Private functions start here.
|
||||
// ***************************************************************
|
||||
|
||||
// function _makeFunctionsPublic(state)
|
||||
//
|
||||
// Functions which will be accessible via 'state' object. When called, these functions will
|
||||
// get the 'state' object as a context.
|
||||
function _makeFunctionsPublic(state) {
|
||||
var methodsDict = {
|
||||
destroy: destroy,
|
||||
fetchAvailableQualities: fetchAvailableQualities,
|
||||
onQualityChange: onQualityChange,
|
||||
showQualityControl: showQualityControl,
|
||||
toggleQuality: toggleQuality
|
||||
};
|
||||
|
||||
state.bindTo(methodsDict, state.videoQualityControl, state);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
this.videoQualityControl.el.off({
|
||||
click: this.videoQualityControl.toggleQuality,
|
||||
destroy: this.videoQualityControl.destroy
|
||||
});
|
||||
this.el.off('.quality');
|
||||
this.videoQualityControl.el.remove();
|
||||
delete this.videoQualityControl;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var element = state.videoQualityControl.el = $(template.toString());
|
||||
state.videoQualityControl.quality = 'large';
|
||||
HtmlUtils.append(state.el.find('.secondary-controls'), HtmlUtils.HTML(element));
|
||||
}
|
||||
|
||||
// function _bindHandlers(state)
|
||||
//
|
||||
// Bind any necessary function callbacks to DOM events (click, mousemove, etc.).
|
||||
function _bindHandlers(state) {
|
||||
state.videoQualityControl.el.on('click',
|
||||
state.videoQualityControl.toggleQuality
|
||||
);
|
||||
state.el.on('play.quality', _.once(
|
||||
state.videoQualityControl.fetchAvailableQualities
|
||||
));
|
||||
|
||||
state.el.on('destroy.quality', state.videoQualityControl.destroy);
|
||||
}
|
||||
|
||||
// ***************************************************************
|
||||
// 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().
|
||||
// ***************************************************************
|
||||
|
||||
/*
|
||||
* @desc Shows quality control. This function will only be called if HD
|
||||
* qualities are available.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
function showQualityControl() {
|
||||
this.videoQualityControl.el.removeClass('is-hidden');
|
||||
}
|
||||
|
||||
// This function can only be called once as _.once has been used.
|
||||
/*
|
||||
* @desc Get the available qualities from YouTube API. Possible values are:
|
||||
['highres', 'hd1080', 'hd720', 'large', 'medium', 'small'].
|
||||
HD are: ['highres', 'hd1080', 'hd720'].
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
function fetchAvailableQualities() {
|
||||
var qualities = this.videoPlayer.player.getAvailableQualityLevels();
|
||||
|
||||
this.config.availableHDQualities = _.intersection(
|
||||
qualities, ['highres', 'hd1080', 'hd720']
|
||||
);
|
||||
|
||||
// HD qualities are available, show video quality control.
|
||||
if (this.config.availableHDQualities.length > 0) {
|
||||
this.trigger('videoQualityControl.showQualityControl');
|
||||
this.trigger('videoQualityControl.onQualityChange', this.videoQualityControl.quality);
|
||||
}
|
||||
// On initialization, force the video quality to be 'large' instead of
|
||||
// 'default'. Otherwise, the player will sometimes switch to HD
|
||||
// automatically, for example when the iframe resizes itself.
|
||||
this.trigger('videoPlayer.handlePlaybackQualityChange',
|
||||
this.videoQualityControl.quality
|
||||
);
|
||||
}
|
||||
|
||||
function onQualityChange(value) {
|
||||
var controlStateStr;
|
||||
this.videoQualityControl.quality = value;
|
||||
if (_.contains(this.config.availableHDQualities, value)) {
|
||||
controlStateStr = gettext('on');
|
||||
this.videoQualityControl.el
|
||||
.addClass('active')
|
||||
.find('.control-text')
|
||||
.text(controlStateStr);
|
||||
} else {
|
||||
controlStateStr = gettext('off');
|
||||
this.videoQualityControl.el
|
||||
.removeClass('active')
|
||||
.find('.control-text')
|
||||
.text(controlStateStr);
|
||||
}
|
||||
}
|
||||
|
||||
// This function toggles the quality of video only if HD qualities are
|
||||
// available.
|
||||
function toggleQuality(event) {
|
||||
var newQuality,
|
||||
value = this.videoQualityControl.quality,
|
||||
isHD = _.contains(this.config.availableHDQualities, value);
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
newQuality = isHD ? 'large' : 'highres';
|
||||
|
||||
this.trigger('videoPlayer.handlePlaybackQualityChange', newQuality);
|
||||
}
|
||||
});
|
||||
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|
||||
366
xmodule/js/src/video/06_video_progress_slider.js
Normal file
366
xmodule/js/src/video/06_video_progress_slider.js
Normal file
@@ -0,0 +1,366 @@
|
||||
(function(requirejs, require, define) {
|
||||
/*
|
||||
"This is as true in everyday life as it is in battle: we are given one life
|
||||
and the decision is ours whether to wait for circumstances to make up our
|
||||
mind, or whether to act, and in acting, to live."
|
||||
— Omar N. Bradley
|
||||
*/
|
||||
|
||||
// VideoProgressSlider module.
|
||||
define(
|
||||
'video/06_video_progress_slider.js',
|
||||
[],
|
||||
function() {
|
||||
var template = [
|
||||
'<div class="slider" role="application" title="',
|
||||
gettext('Video position. Press space to toggle playback'),
|
||||
'"></div>'
|
||||
].join('');
|
||||
|
||||
// VideoProgressSlider() function - what this module "exports".
|
||||
return function(state) {
|
||||
var dfd = $.Deferred();
|
||||
|
||||
state.videoProgressSlider = {};
|
||||
_makeFunctionsPublic(state);
|
||||
_renderElements(state);
|
||||
|
||||
dfd.resolve();
|
||||
return dfd.promise();
|
||||
};
|
||||
|
||||
// ***************************************************************
|
||||
// Private functions start here.
|
||||
// ***************************************************************
|
||||
|
||||
// function _makeFunctionsPublic(state)
|
||||
//
|
||||
// Functions which will be accessible via 'state' object. When called,
|
||||
// these functions will get the 'state' object as a context.
|
||||
|
||||
/* eslint-disable no-use-before-define */
|
||||
function _makeFunctionsPublic(state) {
|
||||
var methodsDict = {
|
||||
destroy: destroy,
|
||||
buildSlider: buildSlider,
|
||||
getRangeParams: getRangeParams,
|
||||
onSlide: onSlide,
|
||||
onStop: onStop,
|
||||
updatePlayTime: updatePlayTime,
|
||||
updateStartEndTimeRegion: updateStartEndTimeRegion,
|
||||
notifyThroughHandleEnd: notifyThroughHandleEnd,
|
||||
getTimeDescription: getTimeDescription,
|
||||
focusSlider: focusSlider
|
||||
};
|
||||
|
||||
state.bindTo(methodsDict, state.videoProgressSlider, state);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
this.videoProgressSlider.el.removeAttr('tabindex').slider('destroy');
|
||||
this.el.off('destroy', this.videoProgressSlider.destroy);
|
||||
delete this.videoProgressSlider;
|
||||
}
|
||||
|
||||
function bindHandlers(state) {
|
||||
state.videoProgressSlider.el.on('keypress', sliderToggle.bind(state));
|
||||
state.el.on('destroy', state.videoProgressSlider.destroy);
|
||||
}
|
||||
/* eslint-enable no-use-before-define */
|
||||
|
||||
// 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) {
|
||||
state.videoProgressSlider.el = $(template);
|
||||
|
||||
state.el.find('.video-controls').prepend(state.videoProgressSlider.el);
|
||||
state.videoProgressSlider.buildSlider();
|
||||
_buildHandle(state);
|
||||
bindHandlers(state);
|
||||
}
|
||||
|
||||
function _buildHandle(state) {
|
||||
state.videoProgressSlider.handle = state.videoProgressSlider.el
|
||||
.find('.ui-slider-handle');
|
||||
|
||||
// ARIA
|
||||
// We just want the knob to be selectable with keyboard
|
||||
state.videoProgressSlider.el.attr({
|
||||
tabindex: -1
|
||||
});
|
||||
|
||||
// Let screen readers know that this div, representing the slider
|
||||
// handle, behaves as a slider named 'video position'.
|
||||
state.videoProgressSlider.handle.attr({
|
||||
role: 'slider',
|
||||
'aria-disabled': false,
|
||||
'aria-valuetext': getTimeDescription(state.videoProgressSlider
|
||||
.slider.slider('option', 'value')),
|
||||
'aria-valuemax': state.videoPlayer.duration(),
|
||||
'aria-valuemin': '0',
|
||||
'aria-valuenow': state.videoPlayer.currentTime,
|
||||
tabindex: '0',
|
||||
'aria-label': gettext('Video position. Press space to toggle playback')
|
||||
});
|
||||
}
|
||||
|
||||
// ***************************************************************
|
||||
// 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 buildSlider() {
|
||||
var sliderContents = edx.HtmlUtils.joinHtml(
|
||||
edx.HtmlUtils.HTML('<div class="ui-slider-handle progress-handle"></div>')
|
||||
);
|
||||
|
||||
// xss-lint: disable=javascript-jquery-append
|
||||
this.videoProgressSlider.el.append(sliderContents.text);
|
||||
|
||||
this.videoProgressSlider.slider = this.videoProgressSlider.el
|
||||
.slider({
|
||||
range: 'min',
|
||||
min: this.config.startTime,
|
||||
max: this.config.endTime,
|
||||
slide: this.videoProgressSlider.onSlide,
|
||||
stop: this.videoProgressSlider.onStop,
|
||||
step: 5
|
||||
});
|
||||
|
||||
this.videoProgressSlider.sliderProgress = this.videoProgressSlider
|
||||
.slider
|
||||
.find('.ui-slider-range.ui-widget-header.ui-slider-range-min');
|
||||
}
|
||||
|
||||
// Rebuild the slider start-end range (if it doesn't take up the
|
||||
// whole slider). Remember that endTime === null means the end-time
|
||||
// is set to the end of video by default.
|
||||
function updateStartEndTimeRegion(params) {
|
||||
var start, end, duration, rangeParams;
|
||||
|
||||
// We must have a duration in order to determine the area of range.
|
||||
// It also must be non-zero.
|
||||
if (!params.duration) {
|
||||
return;
|
||||
} else {
|
||||
duration = params.duration;
|
||||
}
|
||||
|
||||
start = this.config.startTime;
|
||||
end = this.config.endTime;
|
||||
|
||||
if (start > duration) {
|
||||
start = 0;
|
||||
} else if (this.isFlashMode()) {
|
||||
start /= Number(this.speed);
|
||||
}
|
||||
|
||||
// If end is set to null, or it is greater than the duration of the
|
||||
// video, then we set it to the end of the video.
|
||||
if (end === null || end > duration) {
|
||||
end = duration;
|
||||
} else if (this.isFlashMode()) {
|
||||
end /= Number(this.speed);
|
||||
}
|
||||
|
||||
// Don't build a range if it takes up the whole slider.
|
||||
if (start === 0 && end === duration) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Because JavaScript has weird rounding rules when a series of
|
||||
// mathematical operations are performed in a single statement, we will
|
||||
// split everything up into smaller statements.
|
||||
//
|
||||
// This will ensure that visually, the start-end range aligns nicely
|
||||
// with actual starting and ending point of the video.
|
||||
|
||||
rangeParams = getRangeParams(start, end, duration);
|
||||
}
|
||||
|
||||
function getRangeParams(startTime, endTime, duration) {
|
||||
var step = 100 / duration,
|
||||
left = startTime * step,
|
||||
width = endTime * step - left;
|
||||
|
||||
return {
|
||||
left: left + '%',
|
||||
width: width + '%'
|
||||
};
|
||||
}
|
||||
|
||||
function onSlide(event, ui) {
|
||||
var time = ui.value,
|
||||
endTime = this.videoPlayer.duration();
|
||||
|
||||
if (this.config.endTime) {
|
||||
endTime = Math.min(this.config.endTime, endTime);
|
||||
}
|
||||
|
||||
this.videoProgressSlider.frozen = true;
|
||||
|
||||
// Remember the seek to value so that we don't repeat ourselves on the
|
||||
// 'stop' slider event.
|
||||
this.videoProgressSlider.lastSeekValue = time;
|
||||
|
||||
this.trigger(
|
||||
'videoControl.updateVcrVidTime',
|
||||
{
|
||||
time: time,
|
||||
duration: endTime
|
||||
}
|
||||
);
|
||||
|
||||
this.trigger(
|
||||
'videoPlayer.onSlideSeek',
|
||||
{type: 'onSlideSeek', time: time}
|
||||
);
|
||||
|
||||
// ARIA
|
||||
this.videoProgressSlider.handle.attr(
|
||||
'aria-valuetext', getTimeDescription(this.videoPlayer.currentTime)
|
||||
);
|
||||
}
|
||||
|
||||
function onStop(event, ui) {
|
||||
var _this = this;
|
||||
|
||||
this.videoProgressSlider.frozen = true;
|
||||
|
||||
// Only perform a seek if we haven't made a seek for the new slider value.
|
||||
// This is necessary so that if the user only clicks on the slider, without
|
||||
// dragging it, then only one seek is made, even when a 'slide' and a 'stop'
|
||||
// events are triggered on the slider.
|
||||
if (this.videoProgressSlider.lastSeekValue !== ui.value) {
|
||||
this.trigger(
|
||||
'videoPlayer.onSlideSeek',
|
||||
{type: 'onSlideSeek', time: ui.value}
|
||||
);
|
||||
}
|
||||
|
||||
// ARIA
|
||||
this.videoProgressSlider.handle.attr(
|
||||
'aria-valuetext', getTimeDescription(this.videoPlayer.currentTime)
|
||||
);
|
||||
|
||||
setTimeout(function() {
|
||||
_this.videoProgressSlider.frozen = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function updatePlayTime(params) {
|
||||
var time = Math.floor(params.time);
|
||||
// params.duration could accidentally be construed as a floating
|
||||
// point double. Since we're displaying this number, round down
|
||||
// to nearest second
|
||||
var endTime = Math.floor(params.duration);
|
||||
|
||||
if (this.config.endTime !== null) {
|
||||
endTime = Math.min(this.config.endTime, endTime);
|
||||
}
|
||||
|
||||
if (
|
||||
this.videoProgressSlider.slider &&
|
||||
!this.videoProgressSlider.frozen
|
||||
) {
|
||||
this.videoProgressSlider.slider
|
||||
.slider('option', 'max', endTime)
|
||||
.slider('option', 'value', time);
|
||||
}
|
||||
|
||||
// Update aria values.
|
||||
this.videoProgressSlider.handle.attr({
|
||||
'aria-valuemax': endTime,
|
||||
'aria-valuenow': time
|
||||
});
|
||||
}
|
||||
|
||||
// When the video stops playing (either because the end was reached, or
|
||||
// because endTime was reached), the screen reader must be notified that
|
||||
// the video is no longer playing. We do this by a little trick. Setting
|
||||
// the title attribute of the slider know to "video ended", and focusing
|
||||
// on it. The screen reader will read the attr text.
|
||||
//
|
||||
// The user can then tab their way forward, landing on the next control
|
||||
// element, the Play button.
|
||||
//
|
||||
// @param params - object with property `end`. If set to true, the
|
||||
// function must set the title attribute to
|
||||
// `video ended`;
|
||||
// if set to false, the function must reset the attr to
|
||||
// it's original state.
|
||||
//
|
||||
// This function will be triggered from VideoPlayer methods onEnded(),
|
||||
// onPlay(), and update() (update method handles endTime).
|
||||
function notifyThroughHandleEnd(params) {
|
||||
if (params.end) {
|
||||
this.videoProgressSlider.handle
|
||||
.attr('title', gettext('Video ended'))
|
||||
.focus();
|
||||
} else {
|
||||
this.videoProgressSlider.handle
|
||||
.attr('title', gettext('Video position'));
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a string describing the current time of video in
|
||||
// `%d hours %d minutes %d seconds` format.
|
||||
function getTimeDescription(time) {
|
||||
var seconds = Math.floor(time),
|
||||
minutes = Math.floor(seconds / 60),
|
||||
hours = Math.floor(minutes / 60),
|
||||
i18n = function(value, word) {
|
||||
var msg;
|
||||
|
||||
switch (word) {
|
||||
case 'hour':
|
||||
msg = ngettext('%(value)s hour', '%(value)s hours', value);
|
||||
break;
|
||||
case 'minute':
|
||||
msg = ngettext('%(value)s minute', '%(value)s minutes', value);
|
||||
break;
|
||||
case 'second':
|
||||
msg = ngettext('%(value)s second', '%(value)s seconds', value);
|
||||
break;
|
||||
}
|
||||
return interpolate(msg, {value: value}, true);
|
||||
};
|
||||
|
||||
seconds = seconds % 60;
|
||||
minutes = minutes % 60;
|
||||
|
||||
if (hours) {
|
||||
return i18n(hours, 'hour') + ' ' +
|
||||
i18n(minutes, 'minute') + ' ' +
|
||||
i18n(seconds, 'second');
|
||||
} else if (minutes) {
|
||||
return i18n(minutes, 'minute') + ' ' +
|
||||
i18n(seconds, 'second');
|
||||
}
|
||||
|
||||
return i18n(seconds, 'second');
|
||||
}
|
||||
|
||||
// Shift focus to the progress slider container element.
|
||||
function focusSlider() {
|
||||
this.videoProgressSlider.handle.attr(
|
||||
'aria-valuetext', getTimeDescription(this.videoPlayer.currentTime)
|
||||
);
|
||||
this.videoProgressSlider.el.trigger('focus');
|
||||
}
|
||||
|
||||
// Toggle video playback when the spacebar is pushed.
|
||||
function sliderToggle(e) {
|
||||
if (e.which === 32) {
|
||||
e.preventDefault();
|
||||
this.videoCommands.execute('togglePlayback');
|
||||
}
|
||||
}
|
||||
});
|
||||
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|
||||
550
xmodule/js/src/video/07_video_volume_control.js
Normal file
550
xmodule/js/src/video/07_video_volume_control.js
Normal file
@@ -0,0 +1,550 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
// VideoVolumeControl module.
|
||||
define(
|
||||
'video/07_video_volume_control.js', ['edx-ui-toolkit/js/utils/html-utils'],
|
||||
function(HtmlUtils) {
|
||||
/**
|
||||
* Video volume control module.
|
||||
* @exports video/07_video_volume_control.js
|
||||
* @constructor
|
||||
* @param {Object} state The object containing the state of the video
|
||||
* @param {Object} i18n The object containing strings with translations.
|
||||
* @return {jquery Promise}
|
||||
*/
|
||||
var VolumeControl = function(state, i18n) {
|
||||
if (!(this instanceof VolumeControl)) {
|
||||
return new VolumeControl(state, i18n);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'keyDownHandler', 'updateVolumeSilently',
|
||||
'onVolumeChangeHandler', 'openMenu', 'closeMenu',
|
||||
'toggleMuteHandler', 'keyDownButtonHandler', 'destroy'
|
||||
);
|
||||
this.state = state;
|
||||
this.state.videoVolumeControl = this;
|
||||
this.i18n = i18n;
|
||||
this.initialize();
|
||||
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
|
||||
VolumeControl.prototype = {
|
||||
/** Minimum value for the volume slider. */
|
||||
min: 0,
|
||||
/** Maximum value for the volume slider. */
|
||||
max: 100,
|
||||
/** Step to increase/decrease volume level via keyboard. */
|
||||
step: 20,
|
||||
|
||||
videoVolumeControlHtml: HtmlUtils.interpolateHtml(
|
||||
HtmlUtils.HTML([
|
||||
'<div class="volume" role="application">',
|
||||
'<p class="sr instructions">',
|
||||
'{volumeInstructions}',
|
||||
'</p>',
|
||||
'<button class="control" aria-disabled="false"',
|
||||
'" aria-expanded="false" title="',
|
||||
'{adjustVideoVolume}',
|
||||
'" aria-label="',
|
||||
'{adjustVideoVolume}',
|
||||
'">',
|
||||
'<span class="icon fa fa-volume-up" aria-hidden="true"></span>',
|
||||
'</button>',
|
||||
'<div class="volume-slider-container" aria-hidden="true" title="',
|
||||
'{adjustVideoVolume}',
|
||||
'">',
|
||||
'<div class="volume-slider" ',
|
||||
'role="slider"',
|
||||
'aria-orientation="vertical" ',
|
||||
'aria-valuemin="0" ',
|
||||
'aria-valuemax="100" ',
|
||||
'aria-valuenow="" ',
|
||||
'aria-label="',
|
||||
'{volumeText}',
|
||||
'"></div>',
|
||||
'</div>',
|
||||
'</div>'].join('')),
|
||||
{
|
||||
volumeInstructions: gettext('Click on this button to mute or unmute this video or press UP or DOWN buttons to increase or decrease volume level.'), // eslint-disable-line max-len
|
||||
adjustVideoVolume: gettext('Adjust video volume'),
|
||||
volumeText: gettext('Volume')
|
||||
}
|
||||
),
|
||||
|
||||
destroy: function() {
|
||||
this.volumeSlider.slider('destroy');
|
||||
this.state.el.find('iframe').removeAttr('tabindex');
|
||||
this.a11y.destroy();
|
||||
this.cookie = this.a11y = null;
|
||||
this.closeMenu();
|
||||
|
||||
this.state.el
|
||||
.off('play.volume')
|
||||
.off({
|
||||
keydown: this.keyDownHandler,
|
||||
volumechange: this.onVolumeChangeHandler
|
||||
});
|
||||
this.el.off({
|
||||
mouseenter: this.openMenu,
|
||||
mouseleave: this.closeMenu
|
||||
});
|
||||
this.button.off({
|
||||
mousedown: this.toggleMuteHandler,
|
||||
keydown: this.keyDownButtonHandler,
|
||||
focus: this.openMenu,
|
||||
blur: this.closeMenu
|
||||
});
|
||||
this.el.remove();
|
||||
delete this.state.videoVolumeControl;
|
||||
},
|
||||
|
||||
/** Initializes the module. */
|
||||
initialize: function() {
|
||||
var volume;
|
||||
|
||||
if (this.state.isTouch) {
|
||||
// iOS doesn't support volume change
|
||||
return false;
|
||||
}
|
||||
|
||||
this.el = $(this.videoVolumeControlHtml.toString());
|
||||
// Youtube iframe react on key buttons and has his own handlers.
|
||||
// So, we disallow focusing on iframe.
|
||||
this.state.el.find('iframe').attr('tabindex', -1);
|
||||
this.button = this.el.children('.control');
|
||||
this.cookie = new CookieManager(this.min, this.max);
|
||||
this.a11y = new Accessibility(
|
||||
this.button, this.min, this.max, this.i18n
|
||||
);
|
||||
volume = this.cookie.getVolume();
|
||||
this.storedVolume = this.max;
|
||||
|
||||
this.render();
|
||||
this.bindHandlers();
|
||||
this.setVolume(volume, true, false);
|
||||
this.checkMuteButtonStatus(volume);
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates any necessary DOM elements, attach them, and set their,
|
||||
* initial configuration.
|
||||
*/
|
||||
render: function() {
|
||||
var container = this.el.find('.volume-slider'),
|
||||
instructionsId = 'volume-instructions-' + this.state.id;
|
||||
|
||||
HtmlUtils.append(container, HtmlUtils.HTML('<div class="ui-slider-handle volume-handle"></div>'));
|
||||
|
||||
this.volumeSlider = container.slider({
|
||||
orientation: 'vertical',
|
||||
range: 'min',
|
||||
min: this.min,
|
||||
max: this.max,
|
||||
slide: this.onSlideHandler.bind(this)
|
||||
});
|
||||
|
||||
// We provide an independent behavior to adjust volume level.
|
||||
// Therefore, we do not need redundant focusing on slider in TAB
|
||||
// order.
|
||||
container.find('.volume-handle').attr('tabindex', -1);
|
||||
this.state.el.find('.secondary-controls').append(this.el);
|
||||
|
||||
// set dynamic id for instruction element to avoid collisions
|
||||
this.el.find('.instructions').attr('id', instructionsId);
|
||||
this.button.attr('aria-describedby', instructionsId);
|
||||
},
|
||||
|
||||
/** Bind any necessary function callbacks to DOM events. */
|
||||
bindHandlers: function() {
|
||||
this.state.el.on({
|
||||
'play.volume': _.once(this.updateVolumeSilently),
|
||||
volumechange: this.onVolumeChangeHandler
|
||||
});
|
||||
this.state.el.find('.volume').on({
|
||||
mouseenter: this.openMenu,
|
||||
mouseleave: this.closeMenu
|
||||
});
|
||||
this.button.on({
|
||||
keydown: this.keyDownHandler,
|
||||
click: false,
|
||||
mousedown: this.toggleMuteHandler,
|
||||
focus: this.openMenu,
|
||||
blur: this.closeMenu
|
||||
});
|
||||
this.state.el.on('destroy', this.destroy);
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates volume level without updating view and triggering
|
||||
* `volumechange` event.
|
||||
*/
|
||||
updateVolumeSilently: function() {
|
||||
this.state.el.trigger(
|
||||
'volumechange:silent', [this.getVolume()]
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns current volume level.
|
||||
* @return {Number}
|
||||
*/
|
||||
getVolume: function() {
|
||||
return this.volume;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets current volume level.
|
||||
* @param {Number} volume Suggested volume level
|
||||
* @param {Boolean} [silent] Sets the new volume level without
|
||||
* triggering `volumechange` event and updating the cookie.
|
||||
* @param {Boolean} [withoutSlider] Disables updating the slider.
|
||||
*/
|
||||
setVolume: function(volume, silent, withoutSlider) {
|
||||
if (volume === this.getVolume()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.volume = volume;
|
||||
this.a11y.update(this.getVolume());
|
||||
|
||||
if (!withoutSlider) {
|
||||
this.updateSliderView(this.getVolume());
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
this.cookie.setVolume(this.getVolume());
|
||||
this.state.el.trigger('volumechange', [this.getVolume()]);
|
||||
}
|
||||
},
|
||||
|
||||
/** Increases current volume level using previously defined step. */
|
||||
increaseVolume: function() {
|
||||
var volume = Math.min(this.getVolume() + this.step, this.max);
|
||||
|
||||
this.setVolume(volume, false, false);
|
||||
this.el.find('.volume-slider')
|
||||
.attr('aria-valuenow', volume);
|
||||
},
|
||||
|
||||
/** Decreases current volume level using previously defined step. */
|
||||
decreaseVolume: function() {
|
||||
var volume = Math.max(this.getVolume() - this.step, this.min);
|
||||
|
||||
this.setVolume(volume, false, false);
|
||||
this.el.find('.volume-slider')
|
||||
.attr('aria-valuenow', volume);
|
||||
},
|
||||
|
||||
/** Updates volume slider view. */
|
||||
updateSliderView: function(volume) {
|
||||
this.volumeSlider.slider('value', volume);
|
||||
this.el.find('.volume-slider')
|
||||
.attr('aria-valuenow', volume);
|
||||
},
|
||||
|
||||
/**
|
||||
* Mutes or unmutes volume.
|
||||
* @param {Number} muteStatus Flag to mute/unmute volume.
|
||||
*/
|
||||
mute: function(muteStatus) {
|
||||
var volume;
|
||||
|
||||
this.updateMuteButtonView(muteStatus);
|
||||
|
||||
if (muteStatus) {
|
||||
this.storedVolume = this.getVolume() || this.max;
|
||||
}
|
||||
|
||||
volume = muteStatus ? 0 : this.storedVolume;
|
||||
this.setVolume(volume, false, false);
|
||||
this.el.find('.volume-slider')
|
||||
.attr('aria-valuenow', volume);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns current volume state (is it muted or not?).
|
||||
* @return {Boolean}
|
||||
*/
|
||||
getMuteStatus: function() {
|
||||
return this.getVolume() === 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates the volume button view.
|
||||
* @param {Boolean} isMuted Flag to use muted or unmuted view.
|
||||
*/
|
||||
updateMuteButtonView: function(isMuted) {
|
||||
var action = isMuted ? 'addClass' : 'removeClass';
|
||||
|
||||
this.el[action]('is-muted');
|
||||
|
||||
if (isMuted) {
|
||||
this.el
|
||||
.find('.control .icon')
|
||||
.removeClass('fa-volume-up')
|
||||
.addClass('fa-volume-off');
|
||||
} else {
|
||||
this.el
|
||||
.find('.control .icon')
|
||||
.removeClass('fa-volume-off')
|
||||
.addClass('fa-volume-up');
|
||||
}
|
||||
},
|
||||
|
||||
/** Toggles the state of the volume button. */
|
||||
toggleMute: function() {
|
||||
this.mute(!this.getMuteStatus());
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks and updates the state of the volume button relatively to
|
||||
* volume level.
|
||||
* @param {Number} volume Volume level.
|
||||
*/
|
||||
checkMuteButtonStatus: function(volume) {
|
||||
if (volume <= this.min) {
|
||||
this.updateMuteButtonView(true);
|
||||
this.state.el.off('volumechange.is-muted');
|
||||
this.state.el.on('volumechange.is-muted', _.once(function() {
|
||||
this.updateMuteButtonView(false);
|
||||
}.bind(this)));
|
||||
}
|
||||
},
|
||||
|
||||
/** Opens volume menu. */
|
||||
openMenu: function() {
|
||||
this.el.addClass('is-opened');
|
||||
this.button.attr('aria-expanded', 'true');
|
||||
},
|
||||
|
||||
/** Closes speed menu. */
|
||||
closeMenu: function() {
|
||||
this.el.removeClass('is-opened');
|
||||
this.button.attr('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
/**
|
||||
* Keydown event handler for the video container.
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
keyDownHandler: function(event) {
|
||||
// ALT key is used to change (alternate) the function of
|
||||
// other pressed keys. In this case, do nothing.
|
||||
if (event.altKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($(event.target).hasClass('ui-slider-handle')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var KEY = $.ui.keyCode,
|
||||
keyCode = event.keyCode;
|
||||
|
||||
switch (keyCode) {
|
||||
case KEY.UP:
|
||||
// Shift + Arrows keyboard shortcut might be used by
|
||||
// screen readers. In this case, do nothing.
|
||||
if (event.shiftKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.increaseVolume();
|
||||
return false;
|
||||
case KEY.DOWN:
|
||||
// Shift + Arrows keyboard shortcut might be used by
|
||||
// screen readers. In this case, do nothing.
|
||||
if (event.shiftKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.decreaseVolume();
|
||||
return false;
|
||||
|
||||
case KEY.SPACE:
|
||||
case KEY.ENTER:
|
||||
// Shift + Enter keyboard shortcut might be used by
|
||||
// screen readers. In this case, do nothing.
|
||||
if (event.shiftKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.toggleMute();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Keydown event handler for the volume button.
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
keyDownButtonHandler: function(event) {
|
||||
// ALT key is used to change (alternate) the function of
|
||||
// other pressed keys. In this case, do nothing.
|
||||
if (event.altKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var KEY = $.ui.keyCode,
|
||||
keyCode = event.keyCode;
|
||||
|
||||
switch (keyCode) {
|
||||
case KEY.ENTER:
|
||||
case KEY.SPACE:
|
||||
this.toggleMute();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* onSlide callback for the video slider.
|
||||
* @param {jquery Event} event
|
||||
* @param {jqueryuiSlider ui} ui
|
||||
*/
|
||||
onSlideHandler: function(event, ui) {
|
||||
this.setVolume(ui.value, false, true);
|
||||
this.el.find('.volume-slider')
|
||||
.attr('aria-valuenow', ui.volume);
|
||||
},
|
||||
|
||||
/**
|
||||
* Mousedown event handler for the volume button.
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
toggleMuteHandler: function(event) {
|
||||
this.toggleMute();
|
||||
event.preventDefault();
|
||||
},
|
||||
|
||||
/**
|
||||
* Volumechange event handler.
|
||||
* @param {jquery Event} event
|
||||
* @param {Number} volume Volume level.
|
||||
*/
|
||||
onVolumeChangeHandler: function(event, volume) {
|
||||
this.checkMuteButtonStatus(volume);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Module responsible for the accessibility of volume controls.
|
||||
* @constructor
|
||||
* @private
|
||||
* @param {jquery $} button The volume button.
|
||||
* @param {Number} min Minimum value for the volume slider.
|
||||
* @param {Number} max Maximum value for the volume slider.
|
||||
* @param {Object} i18n The object containing strings with translations.
|
||||
*/
|
||||
var Accessibility = function(button, min, max, i18n) {
|
||||
this.min = min;
|
||||
this.max = max;
|
||||
this.button = button;
|
||||
this.i18n = i18n;
|
||||
|
||||
this.initialize();
|
||||
};
|
||||
|
||||
Accessibility.prototype = {
|
||||
destroy: function() {
|
||||
this.liveRegion.remove();
|
||||
},
|
||||
|
||||
/** Initializes the module. */
|
||||
initialize: function() {
|
||||
this.liveRegion = $('<div />', {
|
||||
class: 'sr video-live-region',
|
||||
'aria-hidden': 'false',
|
||||
'aria-live': 'polite'
|
||||
});
|
||||
|
||||
this.button.after(HtmlUtils.HTML(this.liveRegion).toString());
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates text of the live region.
|
||||
* @param {Number} volume Volume level.
|
||||
*/
|
||||
update: function(volume) {
|
||||
this.liveRegion.text([
|
||||
this.getVolumeDescription(volume),
|
||||
this.i18n.Volume + '.'
|
||||
].join(' '));
|
||||
|
||||
$(this.button).parent().find('.volume-slider')
|
||||
.attr('aria-valuenow', volume);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns a string describing the level of volume.
|
||||
* @param {Number} volume Volume level.
|
||||
*/
|
||||
getVolumeDescription: function(volume) {
|
||||
if (volume === 0) {
|
||||
return this.i18n.Muted;
|
||||
} else if (volume <= 20) {
|
||||
return this.i18n['Very low'];
|
||||
} else if (volume <= 40) {
|
||||
return this.i18n.Low;
|
||||
} else if (volume <= 60) {
|
||||
return this.i18n.Average;
|
||||
} else if (volume <= 80) {
|
||||
return this.i18n.Loud;
|
||||
} else if (volume <= 99) {
|
||||
return this.i18n['Very loud'];
|
||||
}
|
||||
|
||||
return this.i18n.Maximum;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Module responsible for the work with volume cookie.
|
||||
* @constructor
|
||||
* @private
|
||||
* @param {Number} min Minimum value for the volume slider.
|
||||
* @param {Number} max Maximum value for the volume slider.
|
||||
*/
|
||||
var CookieManager = function(min, max) {
|
||||
this.min = min;
|
||||
this.max = max;
|
||||
this.cookieName = 'video_player_volume_level';
|
||||
};
|
||||
|
||||
CookieManager.prototype = {
|
||||
/**
|
||||
* Returns volume level from the cookie.
|
||||
* @return {Number} Volume level.
|
||||
*/
|
||||
getVolume: function() {
|
||||
var volume = parseInt($.cookie(this.cookieName), 10);
|
||||
|
||||
if (_.isFinite(volume)) {
|
||||
volume = Math.max(volume, this.min);
|
||||
volume = Math.min(volume, this.max);
|
||||
} else {
|
||||
volume = this.max;
|
||||
}
|
||||
|
||||
return volume;
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates volume cookie.
|
||||
* @param {Number} volume Volume level.
|
||||
*/
|
||||
setVolume: function(value) {
|
||||
$.cookie(this.cookieName, value, {
|
||||
expires: 3650,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return VolumeControl;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
129
xmodule/js/src/video/08_video_auto_advance_control.js
Normal file
129
xmodule/js/src/video/08_video_auto_advance_control.js
Normal file
@@ -0,0 +1,129 @@
|
||||
(function(requirejs, require, define) {
|
||||
'use strict';
|
||||
define(
|
||||
'video/08_video_auto_advance_control.js', [
|
||||
'edx-ui-toolkit/js/utils/html-utils',
|
||||
'underscore'
|
||||
], function(HtmlUtils, _) {
|
||||
/**
|
||||
* Auto advance control module.
|
||||
* @exports video/08_video_auto_advance_control.js
|
||||
* @constructor
|
||||
* @param {object} state The object containing the state of the video player.
|
||||
* @return {jquery Promise}
|
||||
*/
|
||||
var AutoAdvanceControl = function(state) {
|
||||
if (!(this instanceof AutoAdvanceControl)) {
|
||||
return new AutoAdvanceControl(state);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'onClick', 'destroy', 'autoPlay', 'autoAdvance');
|
||||
this.state = state;
|
||||
this.state.videoAutoAdvanceControl = this;
|
||||
this.initialize();
|
||||
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
|
||||
AutoAdvanceControl.prototype = {
|
||||
template: HtmlUtils.interpolateHtml(
|
||||
HtmlUtils.HTML([
|
||||
'<button class="control auto-advance" aria-disabled="false" title="',
|
||||
'{autoAdvanceText}',
|
||||
'">',
|
||||
'<span class="label" aria-hidden="true">',
|
||||
'{autoAdvanceText}',
|
||||
'</span>',
|
||||
'</button>'].join('')),
|
||||
{
|
||||
autoAdvanceText: gettext('Auto-advance')
|
||||
}
|
||||
).toString(),
|
||||
|
||||
destroy: function() {
|
||||
this.el.off({
|
||||
click: this.onClick
|
||||
});
|
||||
this.el.remove();
|
||||
this.state.el.off({
|
||||
ready: this.autoPlay,
|
||||
ended: this.autoAdvance,
|
||||
destroy: this.destroy
|
||||
});
|
||||
delete this.state.videoAutoAdvanceControl;
|
||||
},
|
||||
|
||||
/** Initializes the module. */
|
||||
initialize: function() {
|
||||
var state = this.state;
|
||||
|
||||
this.el = $(this.template);
|
||||
this.render();
|
||||
this.setAutoAdvance(state.auto_advance);
|
||||
this.bindHandlers();
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates any necessary DOM elements, attach them, and set their,
|
||||
* initial configuration.
|
||||
* @param {boolean} enabled Whether auto advance is enabled
|
||||
*/
|
||||
render: function() {
|
||||
this.state.el.find('.secondary-controls').prepend(this.el);
|
||||
},
|
||||
|
||||
/**
|
||||
* Bind any necessary function callbacks to DOM events (click,
|
||||
* mousemove, etc.).
|
||||
*/
|
||||
bindHandlers: function() {
|
||||
this.el.on({
|
||||
click: this.onClick
|
||||
});
|
||||
this.state.el.on({
|
||||
ready: this.autoPlay,
|
||||
ended: this.autoAdvance,
|
||||
destroy: this.destroy
|
||||
});
|
||||
},
|
||||
|
||||
onClick: function(event) {
|
||||
var enabled = !this.state.auto_advance;
|
||||
event.preventDefault();
|
||||
this.setAutoAdvance(enabled);
|
||||
this.el.trigger('autoadvancechange', [enabled]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets or unsets auto advance.
|
||||
* @param {boolean} enabled Sets auto advance.
|
||||
*/
|
||||
setAutoAdvance: function(enabled) {
|
||||
if (enabled) {
|
||||
this.el.addClass('active');
|
||||
} else {
|
||||
this.el.removeClass('active');
|
||||
}
|
||||
},
|
||||
|
||||
autoPlay: function() {
|
||||
// Only autoplay the video if it's the first component of the unit.
|
||||
// If a unit has more than one video, no more than one will autoplay.
|
||||
var isFirstComponent = this.state.el.parents('.vert-0').length === 1;
|
||||
if (this.state.auto_advance && isFirstComponent) {
|
||||
this.state.videoCommands.execute('play');
|
||||
}
|
||||
},
|
||||
|
||||
autoAdvance: function() {
|
||||
if (this.state.auto_advance) {
|
||||
$('.sequence-nav-button.button-next').first().click();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return AutoAdvanceControl;
|
||||
});
|
||||
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|
||||
418
xmodule/js/src/video/08_video_speed_control.js
Normal file
418
xmodule/js/src/video/08_video_speed_control.js
Normal file
@@ -0,0 +1,418 @@
|
||||
(function(requirejs, require, define) {
|
||||
'use strict';
|
||||
define(
|
||||
'video/08_video_speed_control.js', [
|
||||
'video/00_iterator.js',
|
||||
'edx-ui-toolkit/js/utils/html-utils'
|
||||
], function(Iterator, HtmlUtils) {
|
||||
/**
|
||||
* Video speed control module.
|
||||
* @exports video/08_video_speed_control.js
|
||||
* @constructor
|
||||
* @param {object} state The object containing the state of the video player.
|
||||
* @return {jquery Promise}
|
||||
*/
|
||||
var SpeedControl = function(state) {
|
||||
if (!(this instanceof SpeedControl)) {
|
||||
return new SpeedControl(state);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'onSetSpeed', 'onRenderSpeed', 'clickLinkHandler',
|
||||
'keyDownLinkHandler', 'mouseEnterHandler', 'mouseLeaveHandler',
|
||||
'clickMenuHandler', 'keyDownMenuHandler', 'destroy'
|
||||
);
|
||||
this.state = state;
|
||||
this.state.videoSpeedControl = this;
|
||||
this.initialize();
|
||||
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
|
||||
SpeedControl.prototype = {
|
||||
template: [
|
||||
'<div class="speeds menu-container" role="application">',
|
||||
'<p class="sr instructions">',
|
||||
gettext('Press UP to enter the speed menu then use the UP and DOWN arrow keys to navigate the different speeds, then press ENTER to change to the selected speed.'), // eslint-disable-line max-len, indent
|
||||
'</p>',
|
||||
'<button class="control speed-button" aria-disabled="false" aria-expanded="false"',
|
||||
'title="',
|
||||
gettext('Adjust video speed'),
|
||||
'">',
|
||||
'<span>',
|
||||
'<span class="icon fa fa-caret-right" aria-hidden="true"></span>',
|
||||
'</span>',
|
||||
'<span class="label">',
|
||||
gettext('Speed'),
|
||||
' </span>',
|
||||
'<span class="value"></span>',
|
||||
'</button>',
|
||||
'<ol class="video-speeds menu"></ol>',
|
||||
'</div>'
|
||||
].join(''),
|
||||
|
||||
destroy: function() {
|
||||
this.el.off({
|
||||
mouseenter: this.mouseEnterHandler,
|
||||
mouseleave: this.mouseLeaveHandler,
|
||||
click: this.clickMenuHandler,
|
||||
keydown: this.keyDownMenuHandler
|
||||
});
|
||||
|
||||
this.state.el.off({
|
||||
'speed:set': this.onSetSpeed,
|
||||
'speed:render': this.onRenderSpeed
|
||||
});
|
||||
this.closeMenu(true);
|
||||
this.speedsContainer.remove();
|
||||
this.el.remove();
|
||||
delete this.state.videoSpeedControl;
|
||||
},
|
||||
|
||||
/** Initializes the module. */
|
||||
initialize: function() {
|
||||
var state = this.state;
|
||||
|
||||
if (!this.isPlaybackRatesSupported(state)) {
|
||||
console.log(
|
||||
'[Video info]: playbackRate is not supported.'
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
this.el = $(this.template);
|
||||
this.speedsContainer = this.el.find('.video-speeds');
|
||||
this.speedButton = this.el.find('.speed-button');
|
||||
this.render(state.speeds, state.speed);
|
||||
this.setSpeed(state.speed, true, true);
|
||||
this.bindHandlers();
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates any necessary DOM elements, attach them, and set their,
|
||||
* initial configuration.
|
||||
* @param {array} speeds List of speeds available for the player.
|
||||
* @param {string} currentSpeed The current speed set to the player.
|
||||
*/
|
||||
render: function(speeds, currentSpeed) {
|
||||
var speedsContainer = this.speedsContainer,
|
||||
reversedSpeeds = speeds.concat().reverse(),
|
||||
instructionsId = 'speed-instructions-' + this.state.id,
|
||||
speedsList = $.map(reversedSpeeds, function(speed) {
|
||||
return HtmlUtils.interpolateHtml(
|
||||
HtmlUtils.HTML(
|
||||
[
|
||||
'<li data-speed="{speed}">',
|
||||
'<button class="control speed-option" tabindex="-1" aria-pressed="false">',
|
||||
'{speed}x',
|
||||
'</button>',
|
||||
'</li>'
|
||||
].join('')
|
||||
),
|
||||
{
|
||||
speed: speed
|
||||
}
|
||||
).toString();
|
||||
});
|
||||
|
||||
HtmlUtils.setHtml(
|
||||
speedsContainer,
|
||||
HtmlUtils.HTML(speedsList)
|
||||
);
|
||||
this.speedLinks = new Iterator(speedsContainer.find('.speed-option'));
|
||||
HtmlUtils.prepend(
|
||||
this.state.el.find('.secondary-controls'),
|
||||
HtmlUtils.HTML(this.el)
|
||||
);
|
||||
this.setActiveSpeed(currentSpeed);
|
||||
|
||||
// set dynamic id for instruction element to avoid collisions
|
||||
this.el.find('.instructions').attr('id', instructionsId);
|
||||
this.speedButton.attr('aria-describedby', instructionsId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Bind any necessary function callbacks to DOM events (click,
|
||||
* mousemove, etc.).
|
||||
*/
|
||||
bindHandlers: function() {
|
||||
// Attach various events handlers to the speed menu button.
|
||||
this.el.on({
|
||||
mouseenter: this.mouseEnterHandler,
|
||||
mouseleave: this.mouseLeaveHandler,
|
||||
click: this.openMenu,
|
||||
keydown: this.keyDownMenuHandler
|
||||
});
|
||||
|
||||
// Attach click and keydown event handlers to the individual speed
|
||||
// entries.
|
||||
this.speedsContainer.on({
|
||||
click: this.clickLinkHandler,
|
||||
keydown: this.keyDownLinkHandler
|
||||
}, '.speed-option');
|
||||
|
||||
this.state.el.on({
|
||||
'speed:set': this.onSetSpeed,
|
||||
'speed:render': this.onRenderSpeed
|
||||
});
|
||||
this.state.el.on('destroy', this.destroy);
|
||||
},
|
||||
|
||||
onSetSpeed: function(event, speed) {
|
||||
this.setSpeed(speed, true);
|
||||
},
|
||||
|
||||
onRenderSpeed: function(event, speeds, currentSpeed) {
|
||||
this.render(speeds, currentSpeed);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if playbackRate supports by browser. If browser supports, 1.0
|
||||
* should be returned by playbackRate property. In this case, function
|
||||
* return True. Otherwise, False will be returned.
|
||||
* iOS doesn't support speed change.
|
||||
* @param {object} state The object containing the state of the video
|
||||
* player.
|
||||
* @return {boolean}
|
||||
* true: Browser support playbackRate functionality.
|
||||
* false: Browser doesn't support playbackRate functionality.
|
||||
*/
|
||||
isPlaybackRatesSupported: function(state) {
|
||||
var isHtml5 = state.videoType === 'html5',
|
||||
isTouch = state.isTouch,
|
||||
video = document.createElement('video');
|
||||
|
||||
return !isTouch || (isHtml5 && !Boolean(video.playbackRate));
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens speed menu.
|
||||
* @param {boolean} [bindEvent] Click event will be attached on window.
|
||||
*/
|
||||
openMenu: function(bindEvent) {
|
||||
// When speed entries have focus, the menu stays open on
|
||||
// mouseleave. A clickHandler is added to the window
|
||||
// element to have clicks close the menu when they happen
|
||||
// outside of it.
|
||||
if (bindEvent) {
|
||||
$(window).on('click.speedMenu', this.clickMenuHandler);
|
||||
}
|
||||
|
||||
this.el.addClass('is-opened');
|
||||
this.speedButton
|
||||
.attr('tabindex', -1)
|
||||
.attr('aria-expanded', 'true');
|
||||
},
|
||||
|
||||
/**
|
||||
* Closes speed menu.
|
||||
* @param {boolean} [unBindEvent] Click event will be detached from window.
|
||||
*/
|
||||
closeMenu: function(unBindEvent) {
|
||||
// Remove the previously added clickHandler from window element.
|
||||
if (unBindEvent) {
|
||||
$(window).off('click.speedMenu');
|
||||
}
|
||||
|
||||
this.el.removeClass('is-opened');
|
||||
this.speedButton
|
||||
.attr('tabindex', 0)
|
||||
.attr('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets new current speed for the speed control and triggers `speedchange`
|
||||
* event if needed.
|
||||
* @param {string|number} speed Speed to be set.
|
||||
* @param {boolean} [silent] Sets the new speed without triggering
|
||||
* `speedchange` event.
|
||||
* @param {boolean} [forceUpdate] Updates the speed even if it's
|
||||
* not differs from current speed.
|
||||
*/
|
||||
setSpeed: function(speed, silent, forceUpdate) {
|
||||
var newSpeed = this.state.speedToString(speed);
|
||||
if (newSpeed !== this.currentSpeed || forceUpdate) {
|
||||
this.speedsContainer
|
||||
.find('li')
|
||||
.siblings("li[data-speed='" + newSpeed + "']");
|
||||
|
||||
this.speedButton.find('.value').text(newSpeed + 'x');
|
||||
this.currentSpeed = newSpeed;
|
||||
|
||||
if (!silent) {
|
||||
this.el.trigger('speedchange', [newSpeed, this.state.speed]);
|
||||
}
|
||||
}
|
||||
|
||||
this.resetActiveSpeed();
|
||||
this.setActiveSpeed(newSpeed);
|
||||
},
|
||||
|
||||
resetActiveSpeed: function() {
|
||||
var speedOptions = this.speedsContainer.find('li');
|
||||
|
||||
$(speedOptions).each(function(index, el) {
|
||||
$(el).removeClass('is-active')
|
||||
.find('.speed-option')
|
||||
.attr('aria-pressed', 'false');
|
||||
});
|
||||
},
|
||||
|
||||
setActiveSpeed: function(speed) {
|
||||
var speedOption = this.speedsContainer.find('li[data-speed="' + this.state.speedToString(speed) + '"]');
|
||||
|
||||
speedOption.addClass('is-active')
|
||||
.find('.speed-option')
|
||||
.attr('aria-pressed', 'true');
|
||||
|
||||
this.speedButton.attr('title', gettext('Video speed: ') + this.state.speedToString(speed) + 'x');
|
||||
},
|
||||
|
||||
/**
|
||||
* Click event handler for the menu.
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
clickMenuHandler: function() {
|
||||
this.closeMenu();
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Click event handler for speed links.
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
clickLinkHandler: function(event) {
|
||||
var el = $(event.currentTarget).parent(),
|
||||
speed = $(el).data('speed');
|
||||
|
||||
this.resetActiveSpeed();
|
||||
this.setActiveSpeed(speed);
|
||||
this.state.videoCommands.execute('speed', speed);
|
||||
this.closeMenu(true);
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Mouseenter event handler for the menu.
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
mouseEnterHandler: function() {
|
||||
this.openMenu();
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Mouseleave event handler for the menu.
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
mouseLeaveHandler: function() {
|
||||
// Only close the menu is no speed entry has focus.
|
||||
if (!this.speedLinks.list.is(':focus')) {
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Keydown event handler for the menu.
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
keyDownMenuHandler: function(event) {
|
||||
var KEY = $.ui.keyCode,
|
||||
keyCode = event.keyCode;
|
||||
|
||||
switch (keyCode) {
|
||||
// Open menu and focus on last element of list above it.
|
||||
case KEY.ENTER:
|
||||
case KEY.SPACE:
|
||||
case KEY.UP:
|
||||
this.openMenu(true);
|
||||
this.speedLinks.last().focus();
|
||||
break;
|
||||
// Close menu.
|
||||
case KEY.ESCAPE:
|
||||
this.closeMenu(true);
|
||||
break;
|
||||
}
|
||||
// We do not stop propagation and default behavior on a TAB
|
||||
// keypress.
|
||||
return event.keyCode === KEY.TAB;
|
||||
},
|
||||
|
||||
/**
|
||||
* Keydown event handler for speed links.
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
keyDownLinkHandler: function(event) {
|
||||
// ALT key is used to change (alternate) the function of
|
||||
// other pressed keys. In this, do nothing.
|
||||
if (event.altKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var KEY = $.ui.keyCode,
|
||||
self = this,
|
||||
parent = $(event.currentTarget).parent(),
|
||||
index = parent.index(),
|
||||
speed = parent.data('speed');
|
||||
|
||||
switch (event.keyCode) {
|
||||
// Close menu.
|
||||
case KEY.TAB:
|
||||
// Closes menu after 25ms delay to change `tabindex` after
|
||||
// finishing default behavior.
|
||||
setTimeout(function() {
|
||||
self.closeMenu(true);
|
||||
}, 25);
|
||||
|
||||
return true;
|
||||
// Close menu and give focus to speed control.
|
||||
case KEY.ESCAPE:
|
||||
this.closeMenu(true);
|
||||
this.speedButton.focus();
|
||||
|
||||
return false;
|
||||
// Scroll up menu, wrapping at the top. Keep menu open.
|
||||
case KEY.UP:
|
||||
// Shift + Arrows keyboard shortcut might be used by
|
||||
// screen readers. In this, do nothing.
|
||||
if (event.shiftKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.speedLinks.prev(index).focus();
|
||||
return false;
|
||||
// Scroll down menu, wrapping at the bottom. Keep menu
|
||||
// open.
|
||||
case KEY.DOWN:
|
||||
// Shift + Arrows keyboard shortcut might be used by
|
||||
// screen readers. In this, do nothing.
|
||||
if (event.shiftKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.speedLinks.next(index).focus();
|
||||
return false;
|
||||
// Close menu, give focus to speed control and change
|
||||
// speed.
|
||||
case KEY.ENTER:
|
||||
case KEY.SPACE:
|
||||
this.closeMenu(true);
|
||||
this.speedButton.focus();
|
||||
this.setSpeed(this.state.speedToString(speed));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
return SpeedControl;
|
||||
});
|
||||
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|
||||
677
xmodule/js/src/video/095_video_context_menu.js
Normal file
677
xmodule/js/src/video/095_video_context_menu.js
Normal file
@@ -0,0 +1,677 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
// VideoContextMenu module.
|
||||
define(
|
||||
'video/095_video_context_menu.js',
|
||||
['video/00_component.js'],
|
||||
function(Component) {
|
||||
var AbstractItem, AbstractMenu, Menu, Overlay, Submenu, MenuItem;
|
||||
|
||||
AbstractItem = Component.extend({
|
||||
initialize: function(options) {
|
||||
this.options = $.extend(true, {
|
||||
label: '',
|
||||
prefix: 'edx-',
|
||||
dataAttrs: {menu: this},
|
||||
attrs: {},
|
||||
items: [],
|
||||
callback: $.noop,
|
||||
initialize: $.noop
|
||||
}, options);
|
||||
|
||||
this.id = _.uniqueId();
|
||||
this.element = this.createElement();
|
||||
this.element.attr(this.options.attrs).data(this.options.dataAttrs);
|
||||
this.children = [];
|
||||
this.delegateEvents();
|
||||
this.options.initialize.call(this, this);
|
||||
},
|
||||
destroy: function() {
|
||||
_.invoke(this.getChildren(), 'destroy');
|
||||
this.undelegateEvents();
|
||||
this.getElement().remove();
|
||||
},
|
||||
open: function() {
|
||||
this.getElement().addClass('is-opened');
|
||||
return this;
|
||||
},
|
||||
close: function() { },
|
||||
closeSiblings: function() {
|
||||
_.invoke(this.getSiblings(), 'close');
|
||||
return this;
|
||||
},
|
||||
getElement: function() {
|
||||
return this.element;
|
||||
},
|
||||
addChild: function(child) {
|
||||
var firstChild = null,
|
||||
lastChild = null;
|
||||
if (this.hasChildren()) {
|
||||
lastChild = this.getLastChild();
|
||||
lastChild.next = child;
|
||||
firstChild = this.getFirstChild();
|
||||
firstChild.prev = child;
|
||||
}
|
||||
child.parent = this;
|
||||
child.next = firstChild;
|
||||
child.prev = lastChild;
|
||||
this.children.push(child);
|
||||
return this;
|
||||
},
|
||||
getChildren: function() {
|
||||
// Returns the copy.
|
||||
return this.children.concat();
|
||||
},
|
||||
hasChildren: function() {
|
||||
return this.getChildren().length > 0;
|
||||
},
|
||||
getFirstChild: function() {
|
||||
return _.first(this.children);
|
||||
},
|
||||
getLastChild: function() {
|
||||
return _.last(this.children);
|
||||
},
|
||||
bindEvent: function(element, events, handler) {
|
||||
$(element).on(this.addNamespace(events), handler);
|
||||
return this;
|
||||
},
|
||||
getNext: function() {
|
||||
var item = this.next;
|
||||
while (item.isHidden() && this.id !== item.id) { item = item.next; }
|
||||
return item;
|
||||
},
|
||||
getPrev: function() {
|
||||
var item = this.prev;
|
||||
while (item.isHidden() && this.id !== item.id) { item = item.prev; }
|
||||
return item;
|
||||
},
|
||||
createElement: function() {
|
||||
return null;
|
||||
},
|
||||
getRoot: function() {
|
||||
var item = this;
|
||||
while (item.parent) { item = item.parent; }
|
||||
return item;
|
||||
},
|
||||
populateElement: function() { },
|
||||
focus: function() {
|
||||
this.getElement().focus();
|
||||
this.closeSiblings();
|
||||
return this;
|
||||
},
|
||||
isHidden: function() {
|
||||
return this.getElement().is(':hidden');
|
||||
},
|
||||
getSiblings: function() {
|
||||
var items = [],
|
||||
item = this;
|
||||
while (item.next && item.next.id !== this.id) {
|
||||
item = item.next;
|
||||
items.push(item);
|
||||
}
|
||||
return items;
|
||||
},
|
||||
select: function() { },
|
||||
unselect: function() { },
|
||||
setLabel: function() { },
|
||||
itemHandler: function() { },
|
||||
keyDownHandler: function() { },
|
||||
delegateEvents: function() { },
|
||||
undelegateEvents: function() {
|
||||
this.getElement().off('.' + this.id);
|
||||
},
|
||||
addNamespace: function(events) {
|
||||
return _.map(events.split(/\s+/), function(event) {
|
||||
return event + '.' + this.id;
|
||||
}, this).join(' ');
|
||||
}
|
||||
});
|
||||
|
||||
AbstractMenu = AbstractItem.extend({
|
||||
delegateEvents: function() {
|
||||
this.bindEvent(this.getElement(), 'keydown mouseleave mouseover', this.itemHandler.bind(this))
|
||||
.bindEvent(this.getElement(), 'contextmenu', function(event) { event.preventDefault(); });
|
||||
return this;
|
||||
},
|
||||
|
||||
populateElement: function() {
|
||||
var fragment = document.createDocumentFragment();
|
||||
|
||||
_.each(this.getChildren(), function(child) {
|
||||
fragment.appendChild(child.populateElement()[0]);
|
||||
}, this);
|
||||
|
||||
this.appendContent([fragment]);
|
||||
this.isRendered = true;
|
||||
return this.getElement();
|
||||
},
|
||||
|
||||
close: function() {
|
||||
this.closeChildren();
|
||||
this.getElement().removeClass('is-opened');
|
||||
return this;
|
||||
},
|
||||
|
||||
closeChildren: function() {
|
||||
_.invoke(this.getChildren(), 'close');
|
||||
return this;
|
||||
},
|
||||
|
||||
itemHandler: function(event) {
|
||||
event.preventDefault();
|
||||
var item = $(event.target).data('menu');
|
||||
switch (event.type) {
|
||||
case 'keydown':
|
||||
this.keyDownHandler.call(this, event, item);
|
||||
break;
|
||||
case 'mouseover':
|
||||
this.mouseOverHandler.call(this, event, item);
|
||||
break;
|
||||
case 'mouseleave':
|
||||
this.mouseLeaveHandler.call(this, event, item);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
keyDownHandler: function() { },
|
||||
mouseOverHandler: function() { },
|
||||
mouseLeaveHandler: function() { }
|
||||
});
|
||||
|
||||
Menu = AbstractMenu.extend({
|
||||
initialize: function(options, contextmenuElement, container) {
|
||||
this.contextmenuElement = $(contextmenuElement);
|
||||
this.container = $(container);
|
||||
this.overlay = this.getOverlay();
|
||||
AbstractMenu.prototype.initialize.apply(this, arguments);
|
||||
this.build(this, this.options.items);
|
||||
},
|
||||
|
||||
createElement: function() {
|
||||
return $('<ol />', {
|
||||
class: ['contextmenu', this.options.prefix + 'contextmenu'].join(' '),
|
||||
role: 'menu',
|
||||
tabindex: -1
|
||||
});
|
||||
},
|
||||
|
||||
delegateEvents: function() {
|
||||
AbstractMenu.prototype.delegateEvents.call(this);
|
||||
this.bindEvent(this.contextmenuElement, 'contextmenu', this.contextmenuHandler.bind(this))
|
||||
.bindEvent(window, 'resize', _.debounce(this.close.bind(this), 100));
|
||||
return this;
|
||||
},
|
||||
|
||||
destroy: function() {
|
||||
AbstractMenu.prototype.destroy.call(this);
|
||||
this.overlay.destroy();
|
||||
this.contextmenuElement.removeData('contextmenu');
|
||||
return this;
|
||||
},
|
||||
|
||||
undelegateEvents: function() {
|
||||
AbstractMenu.prototype.undelegateEvents.call(this);
|
||||
this.contextmenuElement.off(this.addNamespace('contextmenu'));
|
||||
this.overlay.undelegateEvents();
|
||||
return this;
|
||||
},
|
||||
|
||||
appendContent: function(content) {
|
||||
var $content = $(content);
|
||||
this.getElement().append($content);
|
||||
return this;
|
||||
},
|
||||
|
||||
addChild: function() {
|
||||
AbstractMenu.prototype.addChild.apply(this, arguments);
|
||||
this.next = this.getFirstChild();
|
||||
this.prev = this.getLastChild();
|
||||
return this;
|
||||
},
|
||||
|
||||
build: function(container, items) {
|
||||
_.each(items, function(item) {
|
||||
var child;
|
||||
if (_.has(item, 'items')) {
|
||||
child = this.build((new Submenu(item, this.contextmenuElement)), item.items);
|
||||
} else {
|
||||
child = new MenuItem(item);
|
||||
}
|
||||
container.addChild(child);
|
||||
}, this);
|
||||
return container;
|
||||
},
|
||||
|
||||
focus: function() {
|
||||
this.getElement().focus();
|
||||
return this;
|
||||
},
|
||||
|
||||
open: function() {
|
||||
var $menu = (this.isRendered) ? this.getElement() : this.populateElement();
|
||||
this.container.append($menu);
|
||||
AbstractItem.prototype.open.call(this);
|
||||
this.overlay.show(this.container);
|
||||
return this;
|
||||
},
|
||||
|
||||
close: function() {
|
||||
AbstractMenu.prototype.close.call(this);
|
||||
this.getElement().detach();
|
||||
this.overlay.hide();
|
||||
return this;
|
||||
},
|
||||
|
||||
position: function(event) {
|
||||
this.getElement().position({
|
||||
my: 'left top',
|
||||
of: event,
|
||||
collision: 'flipfit flipfit',
|
||||
within: this.contextmenuElement
|
||||
});
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
pointInContainerBox: function(x, y) {
|
||||
var containerOffset = this.contextmenuElement.offset(),
|
||||
containerBox = {
|
||||
x0: containerOffset.left,
|
||||
y0: containerOffset.top,
|
||||
x1: containerOffset.left + this.contextmenuElement.outerWidth(),
|
||||
y1: containerOffset.top + this.contextmenuElement.outerHeight()
|
||||
};
|
||||
return containerBox.x0 <= x && x <= containerBox.x1 && containerBox.y0 <= y && y <= containerBox.y1;
|
||||
},
|
||||
|
||||
getOverlay: function() {
|
||||
return new Overlay(
|
||||
this.close.bind(this),
|
||||
function(event) {
|
||||
event.preventDefault();
|
||||
if (this.pointInContainerBox(event.pageX, event.pageY)) {
|
||||
this.position(event).focus();
|
||||
this.closeChildren();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
}.bind(this)
|
||||
);
|
||||
},
|
||||
|
||||
contextmenuHandler: function(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.open().position(event).focus();
|
||||
},
|
||||
|
||||
keyDownHandler: function(event, item) {
|
||||
var KEY = $.ui.keyCode,
|
||||
keyCode = event.keyCode;
|
||||
|
||||
switch (keyCode) {
|
||||
case KEY.UP:
|
||||
item.getPrev().focus();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
case KEY.DOWN:
|
||||
item.getNext().focus();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
case KEY.TAB:
|
||||
event.stopPropagation();
|
||||
break;
|
||||
case KEY.ESCAPE:
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
Overlay = Component.extend({
|
||||
ns: '.overlay',
|
||||
initialize: function(clickHandler, contextmenuHandler) {
|
||||
this.element = $('<div />', {
|
||||
class: 'overlay'
|
||||
});
|
||||
this.clickHandler = clickHandler;
|
||||
this.contextmenuHandler = contextmenuHandler;
|
||||
},
|
||||
|
||||
destroy: function() {
|
||||
this.getElement().remove();
|
||||
this.undelegateEvents();
|
||||
},
|
||||
|
||||
getElement: function() {
|
||||
return this.element;
|
||||
},
|
||||
|
||||
hide: function() {
|
||||
this.getElement().detach();
|
||||
this.undelegateEvents();
|
||||
return this;
|
||||
},
|
||||
|
||||
show: function(container) {
|
||||
var $elem = $(this.getElement());
|
||||
$(container).append($elem);
|
||||
this.delegateEvents();
|
||||
return this;
|
||||
},
|
||||
|
||||
delegateEvents: function() {
|
||||
var self = this;
|
||||
$(document)
|
||||
.on('click' + this.ns, function() {
|
||||
if (_.isFunction(self.clickHandler)) {
|
||||
self.clickHandler.apply(this, arguments);
|
||||
}
|
||||
self.hide();
|
||||
})
|
||||
.on('contextmenu' + this.ns, function() {
|
||||
if (_.isFunction(self.contextmenuHandler)) {
|
||||
self.contextmenuHandler.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
return this;
|
||||
},
|
||||
|
||||
undelegateEvents: function() {
|
||||
$(document).off(this.ns);
|
||||
return this;
|
||||
}
|
||||
});
|
||||
|
||||
Submenu = AbstractMenu.extend({
|
||||
initialize: function(options, contextmenuElement) {
|
||||
this.contextmenuElement = contextmenuElement;
|
||||
AbstractMenu.prototype.initialize.apply(this, arguments);
|
||||
},
|
||||
|
||||
createElement: function() {
|
||||
var $spanElem,
|
||||
$listElem,
|
||||
$element = $('<li />', {
|
||||
class: ['submenu-item', 'menu-item', this.options.prefix + 'submenu-item'].join(' '),
|
||||
'aria-expanded': 'false',
|
||||
'aria-haspopup': 'true',
|
||||
'aria-labelledby': 'submenu-item-label-' + this.id,
|
||||
role: 'menuitem',
|
||||
tabindex: -1
|
||||
});
|
||||
|
||||
$spanElem = $('<span />', {
|
||||
id: 'submenu-item-label-' + this.id,
|
||||
text: this.options.label
|
||||
});
|
||||
this.label = $spanElem.appendTo($element);
|
||||
|
||||
$listElem = $('<ol />', {
|
||||
class: ['submenu', this.options.prefix + 'submenu'].join(' '),
|
||||
role: 'menu'
|
||||
});
|
||||
|
||||
this.list = $listElem.appendTo($element);
|
||||
|
||||
return $element;
|
||||
},
|
||||
|
||||
appendContent: function(content) {
|
||||
var $content = $(content);
|
||||
this.list.append($content);
|
||||
return this;
|
||||
},
|
||||
|
||||
setLabel: function(label) {
|
||||
this.label.text(label);
|
||||
return this;
|
||||
},
|
||||
|
||||
openKeyboard: function() {
|
||||
if (this.hasChildren()) {
|
||||
this.open();
|
||||
this.getFirstChild().focus();
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
keyDownHandler: function(event) {
|
||||
var KEY = $.ui.keyCode,
|
||||
keyCode = event.keyCode;
|
||||
|
||||
switch (keyCode) {
|
||||
case KEY.LEFT:
|
||||
this.close().focus();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
case KEY.RIGHT:
|
||||
case KEY.ENTER:
|
||||
case KEY.SPACE:
|
||||
this.openKeyboard();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
open: function() {
|
||||
AbstractMenu.prototype.open.call(this);
|
||||
this.getElement().attr({'aria-expanded': 'true'});
|
||||
this.position();
|
||||
return this;
|
||||
},
|
||||
|
||||
close: function() {
|
||||
AbstractMenu.prototype.close.call(this);
|
||||
this.getElement().attr({'aria-expanded': 'false'});
|
||||
return this;
|
||||
},
|
||||
|
||||
position: function() {
|
||||
this.list.position({
|
||||
my: 'left top',
|
||||
at: 'right top',
|
||||
of: this.getElement(),
|
||||
collision: 'flipfit flipfit',
|
||||
within: this.contextmenuElement
|
||||
});
|
||||
return this;
|
||||
},
|
||||
|
||||
mouseOverHandler: function() {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(this.open.bind(this), 200);
|
||||
this.focus();
|
||||
},
|
||||
|
||||
mouseLeaveHandler: function() {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(this.close.bind(this), 200);
|
||||
this.focus();
|
||||
}
|
||||
});
|
||||
|
||||
MenuItem = AbstractItem.extend({
|
||||
createElement: function() {
|
||||
var classNames = [
|
||||
'menu-item', this.options.prefix + 'menu-item',
|
||||
this.options.isSelected ? 'is-selected' : ''
|
||||
].join(' ');
|
||||
|
||||
return $('<li />', {
|
||||
class: classNames,
|
||||
'aria-selected': this.options.isSelected ? 'true' : 'false',
|
||||
role: 'menuitem',
|
||||
tabindex: -1,
|
||||
text: this.options.label
|
||||
});
|
||||
},
|
||||
|
||||
populateElement: function() {
|
||||
return this.getElement();
|
||||
},
|
||||
|
||||
delegateEvents: function() {
|
||||
this.bindEvent(this.getElement(), 'click keydown contextmenu mouseover', this.itemHandler.bind(this));
|
||||
return this;
|
||||
},
|
||||
|
||||
setLabel: function(label) {
|
||||
this.getElement().text(label);
|
||||
return this;
|
||||
},
|
||||
|
||||
select: function(event) {
|
||||
this.options.callback.call(this, event, this, this.options);
|
||||
this.getElement()
|
||||
.addClass('is-selected')
|
||||
.attr({'aria-selected': 'true'});
|
||||
_.invoke(this.getSiblings(), 'unselect');
|
||||
// Hide the menu.
|
||||
this.getRoot().close();
|
||||
return this;
|
||||
},
|
||||
|
||||
unselect: function() {
|
||||
this.getElement()
|
||||
.removeClass('is-selected')
|
||||
.attr({'aria-selected': 'false'});
|
||||
return this;
|
||||
},
|
||||
|
||||
itemHandler: function(event) {
|
||||
event.preventDefault();
|
||||
switch (event.type) {
|
||||
case 'contextmenu':
|
||||
case 'click':
|
||||
this.select();
|
||||
break;
|
||||
case 'mouseover':
|
||||
this.focus();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
case 'keydown':
|
||||
this.keyDownHandler.call(this, event, this);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
keyDownHandler: function(event) {
|
||||
var KEY = $.ui.keyCode,
|
||||
keyCode = event.keyCode;
|
||||
|
||||
switch (keyCode) {
|
||||
case KEY.RIGHT:
|
||||
event.stopPropagation();
|
||||
break;
|
||||
case KEY.ENTER:
|
||||
case KEY.SPACE:
|
||||
this.select();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// VideoContextMenu() function - what this module 'exports'.
|
||||
return function(state, i18n) {
|
||||
var speedCallback = function(event, menuitem, options) {
|
||||
var speed = parseFloat(options.label);
|
||||
state.videoCommands.execute('speed', speed);
|
||||
},
|
||||
options = {
|
||||
items: [{
|
||||
label: i18n.Play,
|
||||
callback: function() {
|
||||
state.videoCommands.execute('togglePlayback');
|
||||
},
|
||||
initialize: function(menuitem) {
|
||||
state.el.on({
|
||||
play: function() {
|
||||
menuitem.setLabel(i18n.Pause);
|
||||
},
|
||||
pause: function() {
|
||||
menuitem.setLabel(i18n.Play);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, {
|
||||
label: state.videoVolumeControl.getMuteStatus() ? i18n.Unmute : i18n.Mute,
|
||||
callback: function() {
|
||||
state.videoCommands.execute('toggleMute');
|
||||
},
|
||||
initialize: function(menuitem) {
|
||||
state.el.on({
|
||||
volumechange: function() {
|
||||
if (state.videoVolumeControl.getMuteStatus()) {
|
||||
menuitem.setLabel(i18n.Unmute);
|
||||
} else {
|
||||
menuitem.setLabel(i18n.Mute);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, {
|
||||
label: i18n['Fill browser'],
|
||||
callback: function() {
|
||||
state.videoCommands.execute('toggleFullScreen');
|
||||
},
|
||||
initialize: function(menuitem) {
|
||||
state.el.on({
|
||||
fullscreen: function(event, isFullscreen) {
|
||||
if (isFullscreen) {
|
||||
menuitem.setLabel(i18n['Exit full browser']);
|
||||
} else {
|
||||
menuitem.setLabel(i18n['Fill browser']);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, {
|
||||
label: i18n.Speed,
|
||||
items: _.map(state.speeds, function(speed) {
|
||||
var isSelected = parseFloat(speed) === state.speed;
|
||||
return {label: speed + 'x', callback: speedCallback, speed: speed, isSelected: isSelected};
|
||||
}),
|
||||
initialize: function(menuitem) {
|
||||
state.el.on({
|
||||
speedchange: function(event, speed) {
|
||||
var item = menuitem.getChildren().filter(function(item) {
|
||||
return item.options.speed === speed;
|
||||
})[0];
|
||||
if (item) {
|
||||
item.select();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
$.fn.contextmenu = function(container, options) {
|
||||
return this.each(function() {
|
||||
$(this).data('contextmenu', new Menu(options, this, container));
|
||||
});
|
||||
};
|
||||
|
||||
if (!state.isYoutubeType()) {
|
||||
state.el.find('video').contextmenu(state.el, options);
|
||||
state.el.on('destroy', function() {
|
||||
var contextmenu = $(this).find('video').data('contextmenu');
|
||||
if (contextmenu) {
|
||||
contextmenu.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
});
|
||||
}(RequireJS.define));
|
||||
111
xmodule/js/src/video/09_bumper.js
Normal file
111
xmodule/js/src/video/09_bumper.js
Normal file
@@ -0,0 +1,111 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define('video/09_bumper.js', [], function() {
|
||||
/**
|
||||
* VideoBumper module.
|
||||
* @exports video/09_bumper.js
|
||||
* @constructor
|
||||
* @param {Object} player The player factory.
|
||||
* @param {Object} state The object containing the state of the video
|
||||
* @return {jquery Promise}
|
||||
*/
|
||||
var VideoBumper = function(player, state) {
|
||||
if (!(this instanceof VideoBumper)) {
|
||||
return new VideoBumper(player, state);
|
||||
}
|
||||
|
||||
_.bindAll(
|
||||
this, 'showMainVideoHandler', 'destroy', 'skipByDuration', 'destroyAndResolve'
|
||||
);
|
||||
this.dfd = $.Deferred();
|
||||
this.element = state.el;
|
||||
this.element.addClass('is-bumper');
|
||||
this.player = player;
|
||||
this.state = state;
|
||||
this.doNotShowAgain = false;
|
||||
this.state.videoBumper = this;
|
||||
this.bindHandlers();
|
||||
this.initialize();
|
||||
this.maxBumperDuration = 35; // seconds
|
||||
};
|
||||
|
||||
VideoBumper.prototype = {
|
||||
initialize: function() {
|
||||
this.player();
|
||||
},
|
||||
|
||||
getPromise: function() {
|
||||
return this.dfd.promise();
|
||||
},
|
||||
|
||||
showMainVideoHandler: function() {
|
||||
this.state.storage.setItem('isBumperShown', true);
|
||||
setTimeout(function() {
|
||||
this.saveState();
|
||||
this.showMainVideo();
|
||||
}.bind(this), 20);
|
||||
},
|
||||
|
||||
destroyAndResolve: function() {
|
||||
this.destroy();
|
||||
this.dfd.resolve();
|
||||
},
|
||||
|
||||
showMainVideo: function() {
|
||||
if (this.state.videoPlayer) {
|
||||
this.destroyAndResolve();
|
||||
} else {
|
||||
this.state.el.on('initialize', this.destroyAndResolve);
|
||||
}
|
||||
},
|
||||
|
||||
skip: function() {
|
||||
this.element.trigger('skip', [this.doNotShowAgain]);
|
||||
this.showMainVideoHandler();
|
||||
},
|
||||
|
||||
skipAndDoNotShowAgain: function() {
|
||||
this.doNotShowAgain = true;
|
||||
this.skip();
|
||||
},
|
||||
|
||||
skipByDuration: function(event, time) {
|
||||
if (time > this.maxBumperDuration) {
|
||||
this.element.trigger('ended');
|
||||
}
|
||||
},
|
||||
|
||||
bindHandlers: function() {
|
||||
var events = ['ended', 'error'].join(' ');
|
||||
this.element.on(events, this.showMainVideoHandler);
|
||||
this.element.on('timeupdate', this.skipByDuration);
|
||||
},
|
||||
|
||||
saveState: function() {
|
||||
var info = {bumper_last_view_date: true};
|
||||
if (this.doNotShowAgain) {
|
||||
_.extend(info, {bumper_do_not_show_again: true});
|
||||
}
|
||||
if (this.state.videoSaveStatePlugin) {
|
||||
this.state.videoSaveStatePlugin.saveState(true, info);
|
||||
}
|
||||
},
|
||||
|
||||
destroy: function() {
|
||||
var events = ['ended', 'error'].join(' ');
|
||||
this.element.off(events, this.showMainVideoHandler);
|
||||
this.element.off({
|
||||
timeupdate: this.skipByDuration,
|
||||
initialize: this.destroyAndResolve
|
||||
});
|
||||
this.element.removeClass('is-bumper');
|
||||
if (_.isFunction(this.state.videoPlayer.destroy)) {
|
||||
this.state.videoPlayer.destroy();
|
||||
}
|
||||
delete this.state.videoBumper;
|
||||
}
|
||||
};
|
||||
|
||||
return VideoBumper;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
201
xmodule/js/src/video/09_completion.js
Normal file
201
xmodule/js/src/video/09_completion.js
Normal file
@@ -0,0 +1,201 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
/**
|
||||
* Completion handler
|
||||
* @exports video/09_completion.js
|
||||
* @constructor
|
||||
* @param {Object} state The object containing the state of the video
|
||||
* @return {jquery Promise}
|
||||
*/
|
||||
define('video/09_completion.js', [], function() {
|
||||
var VideoCompletionHandler = function(state) {
|
||||
if (!(this instanceof VideoCompletionHandler)) {
|
||||
return new VideoCompletionHandler(state);
|
||||
}
|
||||
this.state = state;
|
||||
this.state.completionHandler = this;
|
||||
this.initialize();
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
|
||||
VideoCompletionHandler.prototype = {
|
||||
|
||||
/** Tears down the VideoCompletionHandler.
|
||||
*
|
||||
* * Removes backreferences from this.state to this.
|
||||
* * Turns off signal handlers.
|
||||
*/
|
||||
destroy: function() {
|
||||
this.el.remove();
|
||||
this.el.off('timeupdate.completion');
|
||||
this.el.off('ended.completion');
|
||||
delete this.state.completionHandler;
|
||||
},
|
||||
|
||||
/** Initializes the VideoCompletionHandler.
|
||||
*
|
||||
* This sets all the instance variables needed to perform
|
||||
* completion calculations.
|
||||
*/
|
||||
initialize: function() {
|
||||
// Attributes with "Time" in the name refer to the number of seconds since
|
||||
// the beginning of the video, except for lastSentTime, which refers to a
|
||||
// timestamp in seconds since the Unix epoch.
|
||||
this.lastSentTime = undefined;
|
||||
this.isComplete = false;
|
||||
this.completionPercentage = this.state.config.completionPercentage;
|
||||
this.startTime = this.state.config.startTime;
|
||||
this.endTime = this.state.config.endTime;
|
||||
this.isEnabled = this.state.config.completionEnabled;
|
||||
if (this.endTime) {
|
||||
this.completeAfterTime = this.calculateCompleteAfterTime(this.startTime, this.endTime);
|
||||
}
|
||||
if (this.isEnabled) {
|
||||
this.bindHandlers();
|
||||
}
|
||||
},
|
||||
|
||||
/** Bind event handler callbacks.
|
||||
*
|
||||
* When ended is triggered, mark the video complete
|
||||
* unconditionally.
|
||||
*
|
||||
* When timeupdate is triggered, check to see if the user has
|
||||
* passed the completeAfterTime in the video, and if so, mark the
|
||||
* video complete.
|
||||
*
|
||||
* When destroy is triggered, clean up outstanding resources.
|
||||
*/
|
||||
bindHandlers: function() {
|
||||
var self = this;
|
||||
|
||||
/** Event handler to check if the video is complete, and submit
|
||||
* a completion if it is.
|
||||
*
|
||||
* If the timeupdate handler doesn't fire after the required
|
||||
* percentage, this will catch any fully complete videos.
|
||||
*/
|
||||
this.state.el.on('ended.completion', function() {
|
||||
self.handleEnded();
|
||||
});
|
||||
|
||||
/** Event handler to check video progress, and mark complete if
|
||||
* greater than completionPercentage
|
||||
*/
|
||||
this.state.el.on('timeupdate.completion', function(ev, currentTime) {
|
||||
self.handleTimeUpdate(currentTime);
|
||||
});
|
||||
|
||||
/** Event handler to receive youtube metadata (if we even are a youtube link),
|
||||
* and mark complete, if youtube will insist on hosting the video itself.
|
||||
*/
|
||||
this.state.el.on('metadata_received', function() {
|
||||
self.checkMetadata();
|
||||
});
|
||||
|
||||
/** Event handler to clean up resources when the video player
|
||||
* is destroyed.
|
||||
*/
|
||||
this.state.el.off('destroy', this.destroy);
|
||||
},
|
||||
|
||||
/** Handler to call when the ended event is triggered */
|
||||
handleEnded: function() {
|
||||
if (this.isComplete) {
|
||||
return;
|
||||
}
|
||||
this.markCompletion();
|
||||
},
|
||||
|
||||
/** Handler to call when a timeupdate event is triggered */
|
||||
handleTimeUpdate: function(currentTime) {
|
||||
var duration;
|
||||
if (this.isComplete) {
|
||||
return;
|
||||
}
|
||||
if (this.lastSentTime !== undefined && currentTime - this.lastSentTime < this.repostDelaySeconds()) {
|
||||
// Throttle attempts to submit in case of network issues
|
||||
return;
|
||||
}
|
||||
if (this.completeAfterTime === undefined) {
|
||||
// Duration is not available at initialization time
|
||||
duration = this.state.videoPlayer.duration();
|
||||
if (!duration) {
|
||||
// duration is not yet set. Wait for another event,
|
||||
// or fall back to 'ended' handler.
|
||||
return;
|
||||
}
|
||||
this.completeAfterTime = this.calculateCompleteAfterTime(this.startTime, duration);
|
||||
}
|
||||
|
||||
if (currentTime > this.completeAfterTime) {
|
||||
this.markCompletion(currentTime);
|
||||
}
|
||||
},
|
||||
|
||||
/** Handler to call when youtube metadata is received */
|
||||
checkMetadata: function() {
|
||||
var metadata = this.state.metadata[this.state.youtubeId()];
|
||||
|
||||
// https://developers.google.com/youtube/v3/docs/videos#contentDetails.contentRating.ytRating
|
||||
if (metadata && metadata.contentRating && metadata.contentRating.ytRating === 'ytAgeRestricted') {
|
||||
// Age-restricted videos won't play in embedded players. Instead, they ask you to watch it on
|
||||
// youtube itself. Which means we can't notice if they complete it. Rather than leaving an
|
||||
// incompletable video in the course, let's just mark it complete right now.
|
||||
if (!this.isComplete) {
|
||||
this.markCompletion();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Submit completion to the LMS */
|
||||
markCompletion: function(currentTime) {
|
||||
var self = this;
|
||||
var errmsg;
|
||||
this.isComplete = true;
|
||||
this.lastSentTime = currentTime;
|
||||
this.state.el.trigger('complete');
|
||||
if (this.state.config.publishCompletionUrl) {
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: this.state.config.publishCompletionUrl,
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
data: JSON.stringify({completion: 1.0}),
|
||||
success: function() {
|
||||
self.state.el.off('timeupdate.completion');
|
||||
self.state.el.off('ended.completion');
|
||||
},
|
||||
error: function(xhr) {
|
||||
/* eslint-disable no-console */
|
||||
self.complete = false;
|
||||
errmsg = 'Failed to submit completion';
|
||||
if (xhr.responseJSON !== undefined) {
|
||||
errmsg += ': ' + xhr.responseJSON.error;
|
||||
}
|
||||
console.warn(errmsg);
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
});
|
||||
} else {
|
||||
/* eslint-disable no-console */
|
||||
console.warn('publishCompletionUrl not defined');
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
},
|
||||
|
||||
/** Determine what point in the video (in seconds from the
|
||||
* beginning) counts as complete.
|
||||
*/
|
||||
calculateCompleteAfterTime: function(startTime, endTime) {
|
||||
return startTime + (endTime - startTime) * this.completionPercentage;
|
||||
},
|
||||
|
||||
/** How many seconds to wait after a POST fails to try again. */
|
||||
repostDelaySeconds: function() {
|
||||
return 3.0;
|
||||
}
|
||||
};
|
||||
return VideoCompletionHandler;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
112
xmodule/js/src/video/09_events_bumper_plugin.js
Normal file
112
xmodule/js/src/video/09_events_bumper_plugin.js
Normal file
@@ -0,0 +1,112 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define('video/09_events_bumper_plugin.js', [], function() {
|
||||
/**
|
||||
* Events module.
|
||||
* @exports video/09_events_bumper_plugin.js
|
||||
* @constructor
|
||||
* @param {Object} state The object containing the state of the video
|
||||
* @param {Object} i18n The object containing strings with translations.
|
||||
* @param {Object} options
|
||||
* @return {jquery Promise}
|
||||
*/
|
||||
var EventsBumperPlugin = function(state, i18n, options) {
|
||||
if (!(this instanceof EventsBumperPlugin)) {
|
||||
return new EventsBumperPlugin(state, i18n, options);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'onReady', 'onPlay', 'onEnded', 'onShowLanguageMenu', 'onHideLanguageMenu', 'onSkip',
|
||||
'onShowCaptions', 'onHideCaptions', 'destroy');
|
||||
this.state = state;
|
||||
this.options = _.extend({}, options);
|
||||
this.state.videoEventsBumperPlugin = this;
|
||||
this.i18n = i18n;
|
||||
this.initialize();
|
||||
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
|
||||
EventsBumperPlugin.moduleName = 'EventsBumperPlugin';
|
||||
EventsBumperPlugin.prototype = {
|
||||
destroy: function() {
|
||||
this.state.el.off(this.events);
|
||||
delete this.state.videoEventsBumperPlugin;
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
this.events = {
|
||||
ready: this.onReady,
|
||||
play: this.onPlay,
|
||||
'ended stop': this.onEnded,
|
||||
skip: this.onSkip,
|
||||
'language_menu:show': this.onShowLanguageMenu,
|
||||
'language_menu:hide': this.onHideLanguageMenu,
|
||||
'captions:show': this.onShowCaptions,
|
||||
'captions:hide': this.onHideCaptions,
|
||||
destroy: this.destroy
|
||||
};
|
||||
this.bindHandlers();
|
||||
},
|
||||
|
||||
bindHandlers: function() {
|
||||
this.state.el.on(this.events);
|
||||
},
|
||||
|
||||
onReady: function() {
|
||||
this.log('edx.video.bumper.loaded');
|
||||
},
|
||||
|
||||
onPlay: function() {
|
||||
this.log('edx.video.bumper.played', {currentTime: this.getCurrentTime()});
|
||||
},
|
||||
|
||||
onEnded: function() {
|
||||
this.log('edx.video.bumper.stopped', {currentTime: this.getCurrentTime()});
|
||||
},
|
||||
|
||||
onSkip: function(event, doNotShowAgain) {
|
||||
var info = {currentTime: this.getCurrentTime()},
|
||||
eventName = 'edx.video.bumper.' + (doNotShowAgain ? 'dismissed' : 'skipped');
|
||||
this.log(eventName, info);
|
||||
},
|
||||
|
||||
onShowLanguageMenu: function() {
|
||||
this.log('edx.video.bumper.transcript.menu.shown');
|
||||
},
|
||||
|
||||
onHideLanguageMenu: function() {
|
||||
this.log('edx.video.bumper.transcript.menu.hidden');
|
||||
},
|
||||
|
||||
onShowCaptions: function() {
|
||||
this.log('edx.video.bumper.transcript.shown', {currentTime: this.getCurrentTime()});
|
||||
},
|
||||
|
||||
onHideCaptions: function() {
|
||||
this.log('edx.video.bumper.transcript.hidden', {currentTime: this.getCurrentTime()});
|
||||
},
|
||||
|
||||
getCurrentTime: function() {
|
||||
var player = this.state.videoPlayer;
|
||||
return player ? player.currentTime : 0;
|
||||
},
|
||||
|
||||
getDuration: function() {
|
||||
var player = this.state.videoPlayer;
|
||||
return player ? player.duration() : 0;
|
||||
},
|
||||
|
||||
log: function(eventName, data) {
|
||||
var logInfo = _.extend({
|
||||
host_component_id: this.state.id,
|
||||
bumper_id: this.state.config.sources[0] || '',
|
||||
duration: this.getDuration(),
|
||||
code: 'html5'
|
||||
}, data, this.options.data);
|
||||
Logger.log(eventName, logInfo);
|
||||
}
|
||||
};
|
||||
|
||||
return EventsBumperPlugin;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
167
xmodule/js/src/video/09_events_plugin.js
Normal file
167
xmodule/js/src/video/09_events_plugin.js
Normal file
@@ -0,0 +1,167 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define('video/09_events_plugin.js', [], function() {
|
||||
/**
|
||||
* Events module.
|
||||
* @exports video/09_events_plugin.js
|
||||
* @constructor
|
||||
* @param {Object} state The object containing the state of the video
|
||||
* @param {Object} i18n The object containing strings with translations.
|
||||
* @param {Object} options
|
||||
* @return {jquery Promise}
|
||||
*/
|
||||
var EventsPlugin = function(state, i18n, options) {
|
||||
if (!(this instanceof EventsPlugin)) {
|
||||
return new EventsPlugin(state, i18n, options);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'onReady', 'onPlay', 'onPause', 'onComplete', 'onEnded', 'onSeek',
|
||||
'onSpeedChange', 'onAutoAdvanceChange', 'onShowLanguageMenu', 'onHideLanguageMenu',
|
||||
'onSkip', 'onShowTranscript', 'onHideTranscript', 'onShowCaptions', 'onHideCaptions',
|
||||
'destroy');
|
||||
|
||||
this.state = state;
|
||||
this.options = _.extend({}, options);
|
||||
this.state.videoEventsPlugin = this;
|
||||
this.i18n = i18n;
|
||||
this.initialize();
|
||||
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
|
||||
EventsPlugin.moduleName = 'EventsPlugin';
|
||||
EventsPlugin.prototype = {
|
||||
destroy: function() {
|
||||
this.state.el.off(this.events);
|
||||
delete this.state.videoEventsPlugin;
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
this.events = {
|
||||
ready: this.onReady,
|
||||
play: this.onPlay,
|
||||
pause: this.onPause,
|
||||
complete: this.onComplete,
|
||||
'ended stop': this.onEnded,
|
||||
seek: this.onSeek,
|
||||
skip: this.onSkip,
|
||||
speedchange: this.onSpeedChange,
|
||||
autoadvancechange: this.onAutoAdvanceChange,
|
||||
'language_menu:show': this.onShowLanguageMenu,
|
||||
'language_menu:hide': this.onHideLanguageMenu,
|
||||
'transcript:show': this.onShowTranscript,
|
||||
'transcript:hide': this.onHideTranscript,
|
||||
'captions:show': this.onShowCaptions,
|
||||
'captions:hide': this.onHideCaptions,
|
||||
destroy: this.destroy
|
||||
};
|
||||
this.bindHandlers();
|
||||
this.emitPlayVideoEvent = true;
|
||||
},
|
||||
|
||||
bindHandlers: function() {
|
||||
this.state.el.on(this.events);
|
||||
},
|
||||
|
||||
onReady: function() {
|
||||
this.log('load_video');
|
||||
},
|
||||
|
||||
onPlay: function() {
|
||||
if (this.emitPlayVideoEvent) {
|
||||
this.log('play_video', {currentTime: this.getCurrentTime()});
|
||||
this.emitPlayVideoEvent = false;
|
||||
}
|
||||
},
|
||||
|
||||
onPause: function() {
|
||||
this.log('pause_video', {currentTime: this.getCurrentTime()});
|
||||
this.emitPlayVideoEvent = true;
|
||||
},
|
||||
|
||||
onComplete: function() {
|
||||
this.log('complete_video', {currentTime: this.getCurrentTime()});
|
||||
},
|
||||
|
||||
onEnded: function() {
|
||||
this.log('stop_video', {currentTime: this.getCurrentTime()});
|
||||
this.emitPlayVideoEvent = true;
|
||||
},
|
||||
|
||||
onSkip: function(event, doNotShowAgain) {
|
||||
var info = {currentTime: this.getCurrentTime()},
|
||||
eventName = doNotShowAgain ? 'do_not_show_again_video' : 'skip_video';
|
||||
this.log(eventName, info);
|
||||
},
|
||||
|
||||
onSeek: function(event, time, oldTime, type) {
|
||||
this.log('seek_video', {
|
||||
old_time: oldTime,
|
||||
new_time: time,
|
||||
type: type
|
||||
});
|
||||
this.emitPlayVideoEvent = true;
|
||||
},
|
||||
|
||||
onSpeedChange: function(event, newSpeed, oldSpeed) {
|
||||
this.log('speed_change_video', {
|
||||
current_time: this.getCurrentTime(),
|
||||
old_speed: this.state.speedToString(oldSpeed),
|
||||
new_speed: this.state.speedToString(newSpeed)
|
||||
});
|
||||
},
|
||||
|
||||
onAutoAdvanceChange: function(event, enabled) {
|
||||
this.log('auto_advance_change_video', {
|
||||
enabled: enabled
|
||||
});
|
||||
},
|
||||
|
||||
onShowLanguageMenu: function() {
|
||||
this.log('edx.video.language_menu.shown');
|
||||
},
|
||||
|
||||
onHideLanguageMenu: function() {
|
||||
this.log('edx.video.language_menu.hidden', {language: this.getCurrentLanguage()});
|
||||
},
|
||||
|
||||
onShowTranscript: function() {
|
||||
this.log('show_transcript', {current_time: this.getCurrentTime()});
|
||||
},
|
||||
|
||||
onHideTranscript: function() {
|
||||
this.log('hide_transcript', {current_time: this.getCurrentTime()});
|
||||
},
|
||||
|
||||
onShowCaptions: function() {
|
||||
this.log('edx.video.closed_captions.shown', {current_time: this.getCurrentTime()});
|
||||
},
|
||||
|
||||
onHideCaptions: function() {
|
||||
this.log('edx.video.closed_captions.hidden', {current_time: this.getCurrentTime()});
|
||||
},
|
||||
|
||||
getCurrentTime: function() {
|
||||
var player = this.state.videoPlayer;
|
||||
return player ? player.currentTime : 0;
|
||||
},
|
||||
|
||||
getCurrentLanguage: function() {
|
||||
var language = this.state.lang;
|
||||
return language;
|
||||
},
|
||||
|
||||
log: function(eventName, data) {
|
||||
var logInfo = _.extend({
|
||||
id: this.state.id,
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
code: this.state.isYoutubeType() ? this.state.youtubeId() : this.state.canPlayHLS ? 'hls' : 'html5',
|
||||
duration: this.state.duration
|
||||
}, data, this.options.data);
|
||||
Logger.log(eventName, logInfo);
|
||||
}
|
||||
};
|
||||
|
||||
return EventsPlugin;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
96
xmodule/js/src/video/09_play_pause_control.js
Normal file
96
xmodule/js/src/video/09_play_pause_control.js
Normal file
@@ -0,0 +1,96 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define('video/09_play_pause_control.js', [], function() {
|
||||
/**
|
||||
* Play/pause control module.
|
||||
* @exports video/09_play_pause_control.js
|
||||
* @constructor
|
||||
* @param {Object} state The object containing the state of the video
|
||||
* @param {Object} i18n The object containing strings with translations.
|
||||
* @return {jquery Promise}
|
||||
*/
|
||||
var PlayPauseControl = function(state, i18n) {
|
||||
if (!(this instanceof PlayPauseControl)) {
|
||||
return new PlayPauseControl(state, i18n);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'play', 'pause', 'onClick', 'destroy');
|
||||
this.state = state;
|
||||
this.state.videoPlayPauseControl = this;
|
||||
this.i18n = i18n;
|
||||
this.initialize();
|
||||
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
|
||||
PlayPauseControl.prototype = {
|
||||
template: [
|
||||
'<button class="control video_control play" aria-disabled="false" aria-label="',
|
||||
gettext('Play'),
|
||||
'">',
|
||||
'<span class="icon fa fa-play" aria-hidden="true"></span>',
|
||||
'</button>'
|
||||
].join(''),
|
||||
|
||||
destroy: function() {
|
||||
this.el.remove();
|
||||
this.state.el.off('destroy', this.destroy);
|
||||
delete this.state.videoPlayPauseControl;
|
||||
},
|
||||
|
||||
/** Initializes the module. */
|
||||
initialize: function() {
|
||||
this.el = $(this.template);
|
||||
this.render();
|
||||
this.bindHandlers();
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates any necessary DOM elements, attach them, and set their,
|
||||
* initial configuration.
|
||||
*/
|
||||
render: function() {
|
||||
this.state.el.find('.vcr').prepend(this.el);
|
||||
},
|
||||
|
||||
/** Bind any necessary function callbacks to DOM events. */
|
||||
bindHandlers: function() {
|
||||
this.el.on({
|
||||
click: this.onClick
|
||||
});
|
||||
this.state.el.on({
|
||||
play: this.play,
|
||||
'pause ended': this.pause,
|
||||
destroy: this.destroy
|
||||
});
|
||||
},
|
||||
|
||||
onClick: function(event) {
|
||||
event.preventDefault();
|
||||
this.state.videoCommands.execute('togglePlayback');
|
||||
},
|
||||
|
||||
play: function() {
|
||||
this.el
|
||||
.addClass('pause')
|
||||
.removeClass('play')
|
||||
.attr({title: gettext('Pause'), 'aria-label': gettext('Pause')})
|
||||
.find('.icon')
|
||||
.removeClass('fa-play')
|
||||
.addClass('fa-pause');
|
||||
},
|
||||
|
||||
pause: function() {
|
||||
this.el
|
||||
.removeClass('pause')
|
||||
.addClass('play')
|
||||
.attr({title: gettext('Play'), 'aria-label': gettext('Play')})
|
||||
.find('.icon')
|
||||
.removeClass('fa-pause')
|
||||
.addClass('fa-play');
|
||||
}
|
||||
};
|
||||
|
||||
return PlayPauseControl;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
87
xmodule/js/src/video/09_play_placeholder.js
Normal file
87
xmodule/js/src/video/09_play_placeholder.js
Normal file
@@ -0,0 +1,87 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define('video/09_play_placeholder.js', [], function() {
|
||||
/**
|
||||
* Play placeholder control module.
|
||||
* @exports video/09_play_placeholder.js
|
||||
* @constructor
|
||||
* @param {Object} state The object containing the state of the video
|
||||
* @param {Object} i18n The object containing strings with translations.
|
||||
* @return {jquery Promise}
|
||||
*/
|
||||
var PlayPlaceholder = function(state, i18n) {
|
||||
if (!(this instanceof PlayPlaceholder)) {
|
||||
return new PlayPlaceholder(state, i18n);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'onClick', 'hide', 'show', 'destroy');
|
||||
this.state = state;
|
||||
this.state.videoPlayPlaceholder = this;
|
||||
this.i18n = i18n;
|
||||
this.initialize();
|
||||
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
|
||||
PlayPlaceholder.prototype = {
|
||||
destroy: function() {
|
||||
this.el.off('click', this.onClick);
|
||||
this.state.el.on({
|
||||
destroy: this.destroy,
|
||||
play: this.hide,
|
||||
'ended pause': this.show
|
||||
});
|
||||
this.hide();
|
||||
delete this.state.videoPlayPlaceholder;
|
||||
},
|
||||
|
||||
/**
|
||||
* Indicates whether the placeholder should be shown. We display it
|
||||
* for html5 videos on iPad and Android devices.
|
||||
* @return {Boolean}
|
||||
*/
|
||||
shouldBeShown: function() {
|
||||
return /iPad|Android/i.test(this.state.isTouch[0]) && !this.state.isYoutubeType();
|
||||
},
|
||||
|
||||
/** Initializes the module. */
|
||||
initialize: function() {
|
||||
if (!this.shouldBeShown()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.el = this.state.el.find('.btn-play');
|
||||
this.bindHandlers();
|
||||
this.show();
|
||||
},
|
||||
|
||||
/** Bind any necessary function callbacks to DOM events. */
|
||||
bindHandlers: function() {
|
||||
this.el.on('click', this.onClick);
|
||||
this.state.el.on({
|
||||
destroy: this.destroy,
|
||||
play: this.hide,
|
||||
'ended pause': this.show
|
||||
});
|
||||
},
|
||||
|
||||
onClick: function() {
|
||||
this.state.videoCommands.execute('play');
|
||||
},
|
||||
|
||||
hide: function() {
|
||||
this.el
|
||||
.addClass('is-hidden')
|
||||
.attr({'aria-hidden': 'true', tabindex: -1});
|
||||
},
|
||||
|
||||
show: function() {
|
||||
this.el
|
||||
.removeClass('is-hidden')
|
||||
.attr({'aria-hidden': 'false', tabindex: 0});
|
||||
}
|
||||
};
|
||||
|
||||
return PlayPlaceholder;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
89
xmodule/js/src/video/09_play_skip_control.js
Normal file
89
xmodule/js/src/video/09_play_skip_control.js
Normal file
@@ -0,0 +1,89 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define('video/09_play_skip_control.js', [], function() {
|
||||
/**
|
||||
* Play/skip control module.
|
||||
* @exports video/09_play_skip_control.js
|
||||
* @constructor
|
||||
* @param {Object} state The object containing the state of the video
|
||||
* @param {Object} i18n The object containing strings with translations.
|
||||
* @return {jquery Promise}
|
||||
*/
|
||||
var PlaySkipControl = function(state, i18n) {
|
||||
if (!(this instanceof PlaySkipControl)) {
|
||||
return new PlaySkipControl(state, i18n);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'play', 'onClick', 'destroy');
|
||||
this.state = state;
|
||||
this.state.videoPlaySkipControl = this;
|
||||
this.i18n = i18n;
|
||||
this.initialize();
|
||||
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
|
||||
PlaySkipControl.prototype = {
|
||||
template: [
|
||||
'<button class="control video_control play play-skip-control" title="',
|
||||
gettext('Play'),
|
||||
'">',
|
||||
'<span class="icon fa fa-play" aria-hidden="true"></span>',
|
||||
'</button>'
|
||||
].join(''),
|
||||
|
||||
destroy: function() {
|
||||
this.el.remove();
|
||||
this.state.el.off('destroy', this.destroy);
|
||||
delete this.state.videoPlaySkipControl;
|
||||
},
|
||||
|
||||
/** Initializes the module. */
|
||||
initialize: function() {
|
||||
this.el = $(this.template);
|
||||
this.render();
|
||||
this.bindHandlers();
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates any necessary DOM elements, attach them, and set their,
|
||||
* initial configuration.
|
||||
*/
|
||||
render: function() {
|
||||
this.state.el.find('.vcr').prepend(this.el);
|
||||
},
|
||||
|
||||
/** Bind any necessary function callbacks to DOM events. */
|
||||
bindHandlers: function() {
|
||||
this.el.on('click', this.onClick);
|
||||
this.state.el.on({
|
||||
play: this.play,
|
||||
destroy: this.destroy
|
||||
});
|
||||
},
|
||||
|
||||
onClick: function(event) {
|
||||
event.preventDefault();
|
||||
if (this.state.videoPlayer.isPlaying()) {
|
||||
this.state.videoCommands.execute('skip');
|
||||
} else {
|
||||
this.state.videoCommands.execute('play');
|
||||
}
|
||||
},
|
||||
|
||||
play: function() {
|
||||
this.el
|
||||
.removeClass('play')
|
||||
.addClass('skip')
|
||||
.attr('title', gettext('Skip'))
|
||||
.find('.icon')
|
||||
.removeClass('fa-play')
|
||||
.addClass('fa-step-forward');
|
||||
// Disable possibility to pause the video.
|
||||
this.state.el.find('video').off('click');
|
||||
}
|
||||
};
|
||||
|
||||
return PlaySkipControl;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
69
xmodule/js/src/video/09_poster.js
Normal file
69
xmodule/js/src/video/09_poster.js
Normal file
@@ -0,0 +1,69 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define('video/09_poster.js', [], function() {
|
||||
/**
|
||||
* Poster module.
|
||||
* @exports video/09_poster.js
|
||||
* @constructor
|
||||
* @param {jquery Element} element
|
||||
* @param {Object} options
|
||||
*/
|
||||
var VideoPoster = function(element, options) {
|
||||
if (!(this instanceof VideoPoster)) {
|
||||
return new VideoPoster(element, options);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'onClick', 'destroy');
|
||||
this.element = element;
|
||||
this.container = element.find('.video-player');
|
||||
this.options = options || {};
|
||||
this.initialize();
|
||||
};
|
||||
|
||||
VideoPoster.moduleName = 'Poster';
|
||||
VideoPoster.prototype = {
|
||||
template: _.template([
|
||||
'<div class="video-pre-roll is-<%- type %> poster" ',
|
||||
'style="background-image: url(<%- url %>)">',
|
||||
'<button class="btn-play btn-pre-roll">',
|
||||
'<img src="/static/images/play.png" alt="">',
|
||||
'<span class="sr">', gettext('Play video'), '</span>',
|
||||
'</button>',
|
||||
'</div>'
|
||||
].join('')),
|
||||
|
||||
initialize: function() {
|
||||
this.el = $(this.template({
|
||||
url: this.options.poster.url,
|
||||
type: this.options.poster.type
|
||||
}));
|
||||
this.element.addClass('is-pre-roll');
|
||||
this.render();
|
||||
this.bindHandlers();
|
||||
},
|
||||
|
||||
bindHandlers: function() {
|
||||
this.el.on('click', this.onClick);
|
||||
this.element.on('destroy', this.destroy);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.container.append(this.el);
|
||||
},
|
||||
|
||||
onClick: function() {
|
||||
if (_.isFunction(this.options.onClick)) {
|
||||
this.options.onClick();
|
||||
}
|
||||
this.destroy();
|
||||
},
|
||||
|
||||
destroy: function() {
|
||||
this.element.off('destroy', this.destroy).removeClass('is-pre-roll');
|
||||
this.el.remove();
|
||||
}
|
||||
};
|
||||
|
||||
return VideoPoster;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
131
xmodule/js/src/video/09_save_state_plugin.js
Normal file
131
xmodule/js/src/video/09_save_state_plugin.js
Normal file
@@ -0,0 +1,131 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define('video/09_save_state_plugin.js', ['underscore', 'time.js'], function(_, Time) {
|
||||
/**
|
||||
* Save state module.
|
||||
* @exports video/09_save_state_plugin.js
|
||||
* @constructor
|
||||
* @param {Object} state The object containing the state of the video
|
||||
* @param {Object} i18n The object containing strings with translations.
|
||||
* @param {Object} options
|
||||
* @return {jquery Promise}
|
||||
*/
|
||||
var SaveStatePlugin = function(state, i18n, options) {
|
||||
if (!(this instanceof SaveStatePlugin)) {
|
||||
return new SaveStatePlugin(state, i18n, options);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'onSpeedChange', 'onAutoAdvanceChange', 'saveStateHandler', 'bindUnloadHandler', 'onUnload',
|
||||
'onYoutubeAvailability', 'onLanguageChange', 'destroy');
|
||||
this.state = state;
|
||||
this.options = _.extend({events: []}, options);
|
||||
this.state.videoSaveStatePlugin = this;
|
||||
this.i18n = i18n;
|
||||
this.initialize();
|
||||
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
|
||||
|
||||
SaveStatePlugin.moduleName = 'SaveStatePlugin';
|
||||
SaveStatePlugin.prototype = {
|
||||
destroy: function() {
|
||||
this.state.el.off(this.events).off('destroy', this.destroy);
|
||||
$(window).off('unload', this.onUnload);
|
||||
delete this.state.videoSaveStatePlugin;
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
this.events = {
|
||||
speedchange: this.onSpeedChange,
|
||||
autoadvancechange: this.onAutoAdvanceChange,
|
||||
play: this.bindUnloadHandler,
|
||||
'pause destroy': this.saveStateHandler,
|
||||
'language_menu:change': this.onLanguageChange,
|
||||
youtube_availability: this.onYoutubeAvailability
|
||||
};
|
||||
this.bindHandlers();
|
||||
},
|
||||
|
||||
bindHandlers: function() {
|
||||
if (this.options.events.length) {
|
||||
_.each(this.options.events, function(eventName) {
|
||||
var callback;
|
||||
if (_.has(this.events, eventName)) {
|
||||
callback = this.events[eventName];
|
||||
this.state.el.on(eventName, callback);
|
||||
}
|
||||
}, this);
|
||||
} else {
|
||||
this.state.el.on(this.events);
|
||||
}
|
||||
this.state.el.on('destroy', this.destroy);
|
||||
},
|
||||
|
||||
bindUnloadHandler: _.once(function() {
|
||||
$(window).on('unload.video', this.onUnload);
|
||||
}),
|
||||
|
||||
onSpeedChange: function(event, newSpeed) {
|
||||
this.saveState(true, {speed: newSpeed});
|
||||
this.state.storage.setItem('speed', newSpeed, true);
|
||||
this.state.storage.setItem('general_speed', newSpeed);
|
||||
},
|
||||
|
||||
onAutoAdvanceChange: function(event, enabled) {
|
||||
this.saveState(true, {auto_advance: enabled});
|
||||
this.state.storage.setItem('auto_advance', enabled);
|
||||
},
|
||||
|
||||
saveStateHandler: function() {
|
||||
this.saveState(true);
|
||||
},
|
||||
|
||||
onUnload: function() {
|
||||
this.saveState();
|
||||
},
|
||||
|
||||
onLanguageChange: function(event, langCode) {
|
||||
this.state.storage.setItem('language', langCode);
|
||||
},
|
||||
|
||||
onYoutubeAvailability: function(event, youtubeIsAvailable) {
|
||||
// Compare what the client-side code has determined Youtube
|
||||
// availability to be (true/false) vs. what the LMS recorded for
|
||||
// this user. The LMS will assume YouTube is available by default.
|
||||
if (youtubeIsAvailable !== this.state.config.recordedYoutubeIsAvailable) {
|
||||
this.saveState(true, {youtube_is_available: youtubeIsAvailable});
|
||||
}
|
||||
},
|
||||
|
||||
saveState: function(async, data) {
|
||||
if (this.state.config.saveStateEnabled) {
|
||||
if (!($.isPlainObject(data))) {
|
||||
data = {
|
||||
saved_video_position: this.state.videoPlayer.currentTime
|
||||
};
|
||||
}
|
||||
|
||||
if (data.speed) {
|
||||
this.state.storage.setItem('speed', data.speed, true);
|
||||
}
|
||||
|
||||
if (_.has(data, 'saved_video_position')) {
|
||||
this.state.storage.setItem('savedVideoPosition', data.saved_video_position, true);
|
||||
data.saved_video_position = Time.formatFull(data.saved_video_position);
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: this.state.config.saveStateUrl,
|
||||
type: 'POST',
|
||||
async: !!async,
|
||||
dataType: 'json',
|
||||
data: data
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return SaveStatePlugin;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
75
xmodule/js/src/video/09_skip_control.js
Normal file
75
xmodule/js/src/video/09_skip_control.js
Normal file
@@ -0,0 +1,75 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
// VideoSkipControl module.
|
||||
define(
|
||||
'video/09_skip_control.js', [],
|
||||
function() {
|
||||
/**
|
||||
* Video skip control module.
|
||||
* @exports video/09_skip_control.js
|
||||
* @constructor
|
||||
* @param {Object} state The object containing the state of the video
|
||||
* @param {Object} i18n The object containing strings with translations.
|
||||
* @return {jquery Promise}
|
||||
*/
|
||||
var SkipControl = function(state, i18n) {
|
||||
if (!(this instanceof SkipControl)) {
|
||||
return new SkipControl(state, i18n);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'onClick', 'render', 'destroy');
|
||||
this.state = state;
|
||||
this.state.videoSkipControl = this;
|
||||
this.i18n = i18n;
|
||||
this.initialize();
|
||||
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
|
||||
SkipControl.prototype = {
|
||||
template: [
|
||||
'<button class="control video_control skip skip-control" aria-disabled="false" title="',
|
||||
gettext('Do not show again'),
|
||||
'">',
|
||||
'<span class="icon fa fa-step-forward" aria-hidden="true"></span>',
|
||||
'</button>'
|
||||
].join(''),
|
||||
|
||||
destroy: function() {
|
||||
this.el.remove();
|
||||
this.state.el.off('.skip');
|
||||
delete this.state.videoSkipControl;
|
||||
},
|
||||
|
||||
/** Initializes the module. */
|
||||
initialize: function() {
|
||||
this.el = $(this.template);
|
||||
this.bindHandlers();
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates any necessary DOM elements, attach them, and set their,
|
||||
* initial configuration.
|
||||
*/
|
||||
render: function() {
|
||||
this.state.el.find('.vcr .control').after(this.el);
|
||||
},
|
||||
|
||||
/** Bind any necessary function callbacks to DOM events. */
|
||||
bindHandlers: function() {
|
||||
this.el.on('click', this.onClick);
|
||||
this.state.el.on({
|
||||
'play.skip': _.once(this.render),
|
||||
'destroy.skip': this.destroy
|
||||
});
|
||||
},
|
||||
|
||||
onClick: function(event) {
|
||||
event.preventDefault();
|
||||
this.state.videoCommands.execute('skip', true);
|
||||
}
|
||||
};
|
||||
|
||||
return SkipControl;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
1382
xmodule/js/src/video/09_video_caption.js
Normal file
1382
xmodule/js/src/video/09_video_caption.js
Normal file
@@ -0,0 +1,1382 @@
|
||||
(function(define) {
|
||||
// VideoCaption module.
|
||||
'use strict';
|
||||
|
||||
define('video/09_video_caption.js', [
|
||||
'video/00_sjson.js',
|
||||
'video/00_async_process.js',
|
||||
'edx-ui-toolkit/js/utils/html-utils',
|
||||
'draggabilly',
|
||||
'time.js',
|
||||
'underscore'
|
||||
], function(Sjson, AsyncProcess, HtmlUtils, Draggabilly, Time, _) {
|
||||
/**
|
||||
* @desc VideoCaption module exports a function.
|
||||
*
|
||||
* @type {function}
|
||||
* @access public
|
||||
*
|
||||
* @param {object} state - The object containing the state of the video
|
||||
* player. All other modules, their parameters, public variables, etc.
|
||||
* are available via this object.
|
||||
*
|
||||
* @this {object} The global window object.
|
||||
*
|
||||
* @returns {jquery Promise}
|
||||
*/
|
||||
var VideoCaption = function(state) {
|
||||
if (!(this instanceof VideoCaption)) {
|
||||
return new VideoCaption(state);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'toggleTranscript', 'onMouseEnter', 'onMouseLeave', 'onMovement',
|
||||
'onContainerMouseEnter', 'onContainerMouseLeave', 'fetchCaption',
|
||||
'onResize', 'pause', 'play', 'onCaptionUpdate', 'onCaptionHandler', 'destroy',
|
||||
'handleKeypress', 'handleKeypressLink', 'openLanguageMenu', 'closeLanguageMenu',
|
||||
'previousLanguageMenuItem', 'nextLanguageMenuItem', 'handleCaptionToggle',
|
||||
'showClosedCaptions', 'hideClosedCaptions', 'toggleClosedCaptions',
|
||||
'updateCaptioningCookie', 'handleCaptioningCookie', 'handleTranscriptToggle',
|
||||
'listenForDragDrop', 'setTranscriptVisibility', 'updateTranscriptCookie'
|
||||
);
|
||||
|
||||
this.state = state;
|
||||
this.state.videoCaption = this;
|
||||
this.renderElements();
|
||||
this.handleCaptioningCookie();
|
||||
this.setTranscriptVisibility();
|
||||
this.listenForDragDrop();
|
||||
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
|
||||
VideoCaption.prototype = {
|
||||
|
||||
destroy: function() {
|
||||
this.state.el
|
||||
.off({
|
||||
'caption:fetch': this.fetchCaption,
|
||||
'caption:resize': this.onResize,
|
||||
'caption:update': this.onCaptionUpdate,
|
||||
ended: this.pause,
|
||||
fullscreen: this.onResize,
|
||||
pause: this.pause,
|
||||
play: this.play,
|
||||
destroy: this.destroy
|
||||
})
|
||||
.removeClass('is-captions-rendered');
|
||||
if (this.fetchXHR && this.fetchXHR.abort) {
|
||||
this.fetchXHR.abort();
|
||||
}
|
||||
if (this.availableTranslationsXHR && this.availableTranslationsXHR.abort) {
|
||||
this.availableTranslationsXHR.abort();
|
||||
}
|
||||
this.subtitlesEl.remove();
|
||||
this.container.remove();
|
||||
delete this.state.videoCaption;
|
||||
},
|
||||
/**
|
||||
* @desc Initiate rendering of elements, and set their initial configuration.
|
||||
*
|
||||
*/
|
||||
renderElements: function() {
|
||||
var languages = this.state.config.transcriptLanguages;
|
||||
|
||||
var langHtml = HtmlUtils.interpolateHtml(
|
||||
HtmlUtils.HTML(
|
||||
[
|
||||
'<div class="grouped-controls">',
|
||||
'<button class="control toggle-captions" aria-disabled="false">',
|
||||
'<span class="icon fa fa-cc" aria-hidden="true"></span>',
|
||||
'</button>',
|
||||
'<button class="control toggle-transcript" aria-disabled="false">',
|
||||
'<span class="icon fa fa-quote-left" aria-hidden="true"></span>',
|
||||
'</button>',
|
||||
'<div class="lang menu-container" role="application">',
|
||||
'<p class="sr instructions" id="lang-instructions-{courseId}"></p>',
|
||||
'<button class="control language-menu" aria-disabled="false"',
|
||||
'aria-describedby="lang-instructions-{courseId}" ',
|
||||
'title="{langTitle}">',
|
||||
'<span class="icon fa fa-caret-left" aria-hidden="true"></span>',
|
||||
'</button>',
|
||||
'</div>',
|
||||
'</div>'
|
||||
].join('')),
|
||||
{
|
||||
langTitle: gettext('Open language menu'),
|
||||
courseId: this.state.id
|
||||
}
|
||||
);
|
||||
|
||||
var subtitlesHtml = HtmlUtils.interpolateHtml(
|
||||
HtmlUtils.HTML(
|
||||
[
|
||||
'<div class="subtitles" role="region" id="transcript-{courseId}">',
|
||||
'<h3 id="transcript-label-{courseId}" class="transcript-title sr"></h3>',
|
||||
'<ol id="transcript-captions-{courseId}" class="subtitles-menu" lang="{courseLang}"></ol>',
|
||||
'</div>'
|
||||
].join('')),
|
||||
{
|
||||
courseId: this.state.id,
|
||||
courseLang: this.state.lang
|
||||
}
|
||||
);
|
||||
|
||||
this.loaded = false;
|
||||
this.subtitlesEl = $(HtmlUtils.ensureHtml(subtitlesHtml).toString());
|
||||
this.subtitlesMenuEl = this.subtitlesEl.find('.subtitles-menu');
|
||||
this.container = $(HtmlUtils.ensureHtml(langHtml).toString());
|
||||
this.captionControlEl = this.container.find('.toggle-captions');
|
||||
this.captionDisplayEl = this.state.el.find('.closed-captions');
|
||||
this.transcriptControlEl = this.container.find('.toggle-transcript');
|
||||
this.languageChooserEl = this.container.find('.lang');
|
||||
this.menuChooserEl = this.languageChooserEl.parent();
|
||||
|
||||
if (_.keys(languages).length) {
|
||||
this.renderLanguageMenu(languages);
|
||||
this.fetchCaption();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Bind any necessary function callbacks to DOM events (click,
|
||||
* mousemove, etc.).
|
||||
*
|
||||
*/
|
||||
bindHandlers: function() {
|
||||
var state = this.state,
|
||||
events = [
|
||||
'mouseover', 'mouseout', 'mousedown', 'click', 'focus', 'blur',
|
||||
'keydown'
|
||||
].join(' ');
|
||||
|
||||
this.captionControlEl.on({
|
||||
click: this.toggleClosedCaptions,
|
||||
keydown: this.handleCaptionToggle
|
||||
});
|
||||
this.transcriptControlEl.on({
|
||||
click: this.toggleTranscript,
|
||||
keydown: this.handleTranscriptToggle
|
||||
});
|
||||
this.subtitlesMenuEl.on({
|
||||
mouseenter: this.onMouseEnter,
|
||||
mouseleave: this.onMouseLeave,
|
||||
mousemove: this.onMovement,
|
||||
mousewheel: this.onMovement,
|
||||
DOMMouseScroll: this.onMovement
|
||||
})
|
||||
.on(events, 'span[data-index]', this.onCaptionHandler);
|
||||
this.container.on({
|
||||
mouseenter: this.onContainerMouseEnter,
|
||||
mouseleave: this.onContainerMouseLeave
|
||||
});
|
||||
|
||||
if (this.showLanguageMenu) {
|
||||
this.languageChooserEl.on({
|
||||
keydown: this.handleKeypress
|
||||
}, '.language-menu');
|
||||
|
||||
this.languageChooserEl.on({
|
||||
keydown: this.handleKeypressLink
|
||||
}, '.control-lang');
|
||||
}
|
||||
|
||||
state.el
|
||||
.on({
|
||||
'caption:fetch': this.fetchCaption,
|
||||
'caption:resize': this.onResize,
|
||||
'caption:update': this.onCaptionUpdate,
|
||||
ended: this.pause,
|
||||
fullscreen: this.onResize,
|
||||
pause: this.pause,
|
||||
play: this.play,
|
||||
destroy: this.destroy
|
||||
});
|
||||
|
||||
if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
|
||||
this.subtitlesMenuEl.on('scroll', state.videoControl.showControls);
|
||||
}
|
||||
},
|
||||
|
||||
onCaptionUpdate: function(event, time) {
|
||||
this.updatePlayTime(time);
|
||||
},
|
||||
|
||||
handleCaptionToggle: function(event) {
|
||||
var KEY = $.ui.keyCode,
|
||||
keyCode = event.keyCode;
|
||||
|
||||
switch (keyCode) {
|
||||
case KEY.SPACE:
|
||||
case KEY.ENTER:
|
||||
event.preventDefault();
|
||||
this.toggleClosedCaptions(event);
|
||||
// no default
|
||||
}
|
||||
},
|
||||
|
||||
handleTranscriptToggle: function(event) {
|
||||
var KEY = $.ui.keyCode,
|
||||
keyCode = event.keyCode;
|
||||
|
||||
switch (keyCode) {
|
||||
case KEY.SPACE:
|
||||
case KEY.ENTER:
|
||||
event.preventDefault();
|
||||
this.toggleTranscript(event);
|
||||
// no default
|
||||
}
|
||||
},
|
||||
|
||||
handleKeypressLink: function(event) {
|
||||
var KEY = $.ui.keyCode,
|
||||
keyCode = event.keyCode,
|
||||
focused, index, total;
|
||||
|
||||
switch (keyCode) {
|
||||
case KEY.UP:
|
||||
event.preventDefault();
|
||||
focused = $(':focus').parent();
|
||||
index = this.languageChooserEl.find('li').index(focused);
|
||||
total = this.languageChooserEl.find('li').size() - 1;
|
||||
|
||||
this.previousLanguageMenuItem(event, index);
|
||||
break;
|
||||
|
||||
case KEY.DOWN:
|
||||
event.preventDefault();
|
||||
focused = $(':focus').parent();
|
||||
index = this.languageChooserEl.find('li').index(focused);
|
||||
total = this.languageChooserEl.find('li').size() - 1;
|
||||
|
||||
this.nextLanguageMenuItem(event, index, total);
|
||||
break;
|
||||
|
||||
case KEY.ESCAPE:
|
||||
this.closeLanguageMenu(event);
|
||||
break;
|
||||
|
||||
case KEY.ENTER:
|
||||
case KEY.SPACE:
|
||||
return true;
|
||||
// no default
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
handleKeypress: function(event) {
|
||||
var KEY = $.ui.keyCode,
|
||||
keyCode = event.keyCode;
|
||||
|
||||
switch (keyCode) {
|
||||
// Handle keypresses
|
||||
case KEY.ENTER:
|
||||
case KEY.SPACE:
|
||||
case KEY.UP:
|
||||
event.preventDefault();
|
||||
this.openLanguageMenu(event);
|
||||
break;
|
||||
|
||||
case KEY.ESCAPE:
|
||||
this.closeLanguageMenu(event);
|
||||
break;
|
||||
// no default
|
||||
}
|
||||
|
||||
return event.keyCode === KEY.TAB;
|
||||
},
|
||||
|
||||
nextLanguageMenuItem: function(event, index, total) {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.altKey || event.shiftKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (index === total) {
|
||||
this.languageChooserEl
|
||||
.find('.control-lang').first()
|
||||
.focus();
|
||||
} else {
|
||||
this.languageChooserEl
|
||||
.find('li:eq(' + index + ')')
|
||||
.next()
|
||||
.find('.control-lang')
|
||||
.focus();
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
previousLanguageMenuItem: function(event, index) {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.altKey || event.shiftKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
this.languageChooserEl
|
||||
.find('.control-lang').last()
|
||||
.focus();
|
||||
} else {
|
||||
this.languageChooserEl
|
||||
.find('li:eq(' + index + ')')
|
||||
.prev()
|
||||
.find('.control-lang')
|
||||
.focus();
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
openLanguageMenu: function(event) {
|
||||
var button = this.languageChooserEl,
|
||||
menu = button.parent().find('.menu');
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
button
|
||||
.addClass('is-opened');
|
||||
|
||||
menu
|
||||
.find('.control-lang').last()
|
||||
.focus();
|
||||
},
|
||||
|
||||
closeLanguageMenu: function(event) {
|
||||
var button = this.languageChooserEl;
|
||||
event.preventDefault();
|
||||
|
||||
button
|
||||
.removeClass('is-opened')
|
||||
.find('.language-menu')
|
||||
.focus();
|
||||
},
|
||||
|
||||
onCaptionHandler: function(event) {
|
||||
switch (event.type) {
|
||||
case 'mouseover':
|
||||
case 'mouseout':
|
||||
this.captionMouseOverOut(event);
|
||||
break;
|
||||
case 'mousedown':
|
||||
this.captionMouseDown(event);
|
||||
break;
|
||||
case 'click':
|
||||
this.captionClick(event);
|
||||
break;
|
||||
case 'focusin':
|
||||
this.captionFocus(event);
|
||||
break;
|
||||
case 'focusout':
|
||||
this.captionBlur(event);
|
||||
break;
|
||||
case 'keydown':
|
||||
this.captionKeyDown(event);
|
||||
break;
|
||||
// no default
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Opens language menu.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
onContainerMouseEnter: function(event) {
|
||||
event.preventDefault();
|
||||
$(event.currentTarget).find('.lang').addClass('is-opened');
|
||||
|
||||
// We only want to fire the analytics event if a menu is
|
||||
// present instead of on the container hover, since it wraps
|
||||
// the "CC" and "Transcript" buttons as well.
|
||||
if ($(event.currentTarget).find('.lang').length) {
|
||||
this.state.el.trigger('language_menu:show');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Closes language menu.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
onContainerMouseLeave: function(event) {
|
||||
event.preventDefault();
|
||||
$(event.currentTarget).find('.lang').removeClass('is-opened');
|
||||
|
||||
// We only want to fire the analytics event if a menu is
|
||||
// present instead of on the container hover, since it wraps
|
||||
// the "CC" and "Transcript" buttons as well.
|
||||
if ($(event.currentTarget).find('.lang').length) {
|
||||
this.state.el.trigger('language_menu:hide');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Freezes moving of captions when mouse is over them.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
onMouseEnter: function() {
|
||||
if (this.frozen) {
|
||||
clearTimeout(this.frozen);
|
||||
}
|
||||
|
||||
this.frozen = setTimeout(
|
||||
this.onMouseLeave,
|
||||
this.state.config.captionsFreezeTime
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Unfreezes moving of captions when mouse go out.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
onMouseLeave: function() {
|
||||
if (this.frozen) {
|
||||
clearTimeout(this.frozen);
|
||||
}
|
||||
|
||||
this.frozen = null;
|
||||
|
||||
if (this.playing) {
|
||||
this.scrollCaption();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Freezes moving of captions when mouse is moving over them.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*/
|
||||
onMovement: function() {
|
||||
this.onMouseEnter();
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Gets the correct start and end times from the state configuration
|
||||
*
|
||||
* @returns {array} if [startTime, endTime] are defined
|
||||
*/
|
||||
getStartEndTimes: function() {
|
||||
// due to the way config.startTime/endTime are
|
||||
// processed in 03_video_player.js, we assume
|
||||
// endTime can be an integer or null,
|
||||
// and startTime is an integer > 0
|
||||
var config = this.state.config;
|
||||
var startTime = config.startTime * 1000;
|
||||
var endTime = (config.endTime !== null) ? config.endTime * 1000 : null;
|
||||
return [startTime, endTime];
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Gets captions within the start / end times stored within this.state.config
|
||||
*
|
||||
* @returns {object} {start, captions} parallel arrays of
|
||||
* start times and corresponding captions
|
||||
*/
|
||||
getBoundedCaptions: function() {
|
||||
// get start and caption. If startTime and endTime
|
||||
// are specified, filter by that range.
|
||||
var times = this.getStartEndTimes();
|
||||
var results = this.sjson.filter.apply(this.sjson, times);
|
||||
var start = results.start;
|
||||
var captions = results.captions;
|
||||
|
||||
return {
|
||||
start: start,
|
||||
captions: captions
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Fetch the caption file specified by the user. Upon successful
|
||||
* receipt of the file, the captions will be rendered.
|
||||
* @param {boolean} [fetchWithYoutubeId] Fetch youtube captions if true.
|
||||
* @returns {boolean}
|
||||
* true: The user specified a caption file. NOTE: if an error happens
|
||||
* while the specified file is being retrieved (for example the
|
||||
* file is missing on the server), this function will still return
|
||||
* true.
|
||||
* false: No caption file was specified, or an empty string was
|
||||
* specified for the Youtube type player.
|
||||
*/
|
||||
fetchCaption: function(fetchWithYoutubeId) {
|
||||
var self = this,
|
||||
state = this.state,
|
||||
language = state.getCurrentLanguage(),
|
||||
url = state.config.transcriptTranslationUrl.replace('__lang__', language),
|
||||
data, youtubeId;
|
||||
|
||||
if (this.loaded) {
|
||||
this.hideCaptions(false);
|
||||
}
|
||||
|
||||
if (this.fetchXHR && this.fetchXHR.abort) {
|
||||
this.fetchXHR.abort();
|
||||
}
|
||||
|
||||
if (state.videoType === 'youtube' || fetchWithYoutubeId) {
|
||||
try {
|
||||
youtubeId = state.youtubeId('1.0');
|
||||
} catch (err) {
|
||||
youtubeId = null;
|
||||
}
|
||||
|
||||
if (!youtubeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
data = {videoId: youtubeId};
|
||||
}
|
||||
|
||||
state.el.removeClass('is-captions-rendered');
|
||||
// Fetch the captions file. If no file was specified, or if an error
|
||||
// occurred, then we hide the captions panel, and the "Transcript" button
|
||||
this.fetchXHR = $.ajaxWithPrefix({
|
||||
url: url,
|
||||
notifyOnError: false,
|
||||
data: data,
|
||||
success: function(sjson) {
|
||||
var results, start, captions;
|
||||
self.sjson = new Sjson(sjson);
|
||||
results = self.getBoundedCaptions();
|
||||
start = results.start;
|
||||
captions = results.captions;
|
||||
|
||||
if (self.loaded) {
|
||||
if (self.rendered) {
|
||||
self.renderCaption(start, captions);
|
||||
self.updatePlayTime(state.videoPlayer.currentTime);
|
||||
}
|
||||
} else {
|
||||
if (state.isTouch) {
|
||||
HtmlUtils.setHtml(
|
||||
self.subtitlesEl.find('.subtitles-menu'),
|
||||
HtmlUtils.joinHtml(
|
||||
HtmlUtils.HTML('<li>'),
|
||||
gettext('Transcript will be displayed when you start playing the video.'),
|
||||
HtmlUtils.HTML('</li>')
|
||||
)
|
||||
);
|
||||
} else {
|
||||
self.renderCaption(start, captions);
|
||||
}
|
||||
self.hideCaptions(self.hideCaptionsOnLoad);
|
||||
HtmlUtils.append(
|
||||
self.state.el.find('.video-wrapper').parent(),
|
||||
HtmlUtils.HTML(self.subtitlesEl)
|
||||
);
|
||||
HtmlUtils.append(
|
||||
self.state.el.find('.secondary-controls'),
|
||||
HtmlUtils.HTML(self.container)
|
||||
);
|
||||
self.bindHandlers();
|
||||
}
|
||||
|
||||
self.loaded = true;
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
var canFetchWithYoutubeId;
|
||||
console.log('[Video info]: ERROR while fetching captions.');
|
||||
console.log(
|
||||
'[Video info]: STATUS:', textStatus +
|
||||
', MESSAGE:', '' + errorThrown
|
||||
);
|
||||
// If initial list of languages has more than 1 item, check
|
||||
// for availability other transcripts.
|
||||
// If player mode is html5 and there are no initial languages
|
||||
// then try to fetch youtube version of transcript with
|
||||
// youtubeId.
|
||||
if (_.keys(state.config.transcriptLanguages).length > 1) {
|
||||
self.fetchAvailableTranslations();
|
||||
} else if (!fetchWithYoutubeId && state.videoType === 'html5') {
|
||||
canFetchWithYoutubeId = self.fetchCaption(true);
|
||||
if (canFetchWithYoutubeId) {
|
||||
console.log('[Video info]: Html5 mode fetching caption with youtubeId.'); // eslint-disable-line max-len, no-console
|
||||
} else {
|
||||
self.hideCaptions(true);
|
||||
self.languageChooserEl.hide();
|
||||
self.hideClosedCaptions();
|
||||
}
|
||||
} else {
|
||||
self.hideCaptions(true);
|
||||
self.languageChooserEl.hide();
|
||||
self.hideClosedCaptions();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Fetch the list of available language codes. Upon successful receipt
|
||||
* the list of available languages will be updated.
|
||||
*
|
||||
* @returns {jquery Promise}
|
||||
*/
|
||||
fetchAvailableTranslations: function() {
|
||||
var self = this,
|
||||
state = this.state;
|
||||
|
||||
this.availableTranslationsXHR = $.ajaxWithPrefix({
|
||||
url: state.config.transcriptAvailableTranslationsUrl,
|
||||
notifyOnError: false,
|
||||
success: function(response) {
|
||||
var currentLanguages = state.config.transcriptLanguages,
|
||||
newLanguages = _.pick(currentLanguages, response);
|
||||
|
||||
// Update property with available currently translations.
|
||||
state.config.transcriptLanguages = newLanguages;
|
||||
// Remove an old language menu.
|
||||
self.container.find('.langs-list').remove();
|
||||
|
||||
if (_.keys(newLanguages).length) {
|
||||
self.renderLanguageMenu(newLanguages);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
self.hideCaptions(true);
|
||||
self.languageChooserEl.hide();
|
||||
}
|
||||
});
|
||||
|
||||
return this.availableTranslationsXHR;
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Recalculates and updates the height of the container of captions.
|
||||
*
|
||||
*/
|
||||
onResize: function() {
|
||||
this.subtitlesEl
|
||||
.find('.spacing').first()
|
||||
.height(this.topSpacingHeight());
|
||||
|
||||
this.subtitlesEl
|
||||
.find('.spacing').last()
|
||||
.height(this.bottomSpacingHeight());
|
||||
|
||||
this.scrollCaption();
|
||||
this.setSubtitlesHeight();
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Create any necessary DOM elements, attach them, and set their
|
||||
* initial configuration for the Language menu.
|
||||
*
|
||||
* @param {object} languages Dictionary where key is language code,
|
||||
* value - language label
|
||||
*
|
||||
*/
|
||||
renderLanguageMenu: function(languages) {
|
||||
var self = this,
|
||||
state = this.state,
|
||||
$menu = $('<ol class="langs-list menu">'),
|
||||
currentLang = state.getCurrentLanguage(),
|
||||
$li, $link, linkHtml;
|
||||
|
||||
if (_.keys(languages).length < 2) {
|
||||
// Remove the menu toggle button
|
||||
self.container.find('.lang').remove();
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLanguageMenu = true;
|
||||
|
||||
$.each(languages, function(code, label) {
|
||||
$li = $('<li />', {'data-lang-code': code});
|
||||
linkHtml = HtmlUtils.joinHtml(
|
||||
HtmlUtils.HTML('<button class="control control-lang">'),
|
||||
label,
|
||||
HtmlUtils.HTML('</button>')
|
||||
);
|
||||
$link = $(linkHtml.toString());
|
||||
|
||||
if (currentLang === code) {
|
||||
$li.addClass('is-active');
|
||||
$link.attr('aria-pressed', 'true');
|
||||
}
|
||||
|
||||
$li.append($link);
|
||||
$menu.append($li);
|
||||
});
|
||||
|
||||
HtmlUtils.append(
|
||||
this.languageChooserEl,
|
||||
HtmlUtils.HTML($menu)
|
||||
);
|
||||
|
||||
$menu.on('click', '.control-lang', function(e) {
|
||||
var el = $(e.currentTarget).parent(),
|
||||
captionState = self.state,
|
||||
langCode = el.data('lang-code');
|
||||
|
||||
if (captionState.lang !== langCode) {
|
||||
captionState.lang = langCode;
|
||||
el.addClass('is-active')
|
||||
.siblings('li')
|
||||
.removeClass('is-active')
|
||||
.find('.control-lang')
|
||||
.attr('aria-pressed', 'false');
|
||||
|
||||
$(e.currentTarget).attr('aria-pressed', 'true');
|
||||
|
||||
captionState.el.trigger('language_menu:change', [langCode]);
|
||||
self.fetchCaption();
|
||||
|
||||
// update the closed-captions lang attribute
|
||||
self.captionDisplayEl.attr('lang', langCode);
|
||||
|
||||
// update the transcript lang attribute
|
||||
self.subtitlesMenuEl.attr('lang', langCode);
|
||||
self.closeLanguageMenu(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Create any necessary DOM elements, attach them, and set their
|
||||
* initial configuration.
|
||||
*
|
||||
* @param {jQuery element} container Element in which captions will be
|
||||
* inserted.
|
||||
* @param {array} start List of start times for the video.
|
||||
* @param {array} captions List of captions for the video.
|
||||
* @returns {object} jQuery's Promise object
|
||||
*
|
||||
*/
|
||||
buildCaptions: function(container, start, captions) {
|
||||
var process = function(text, index) {
|
||||
var $spanEl = $('<span>', {
|
||||
role: 'link',
|
||||
'data-index': index,
|
||||
'data-start': start[index],
|
||||
tabindex: 0
|
||||
});
|
||||
|
||||
HtmlUtils.setHtml($($spanEl), HtmlUtils.HTML(text.toString()));
|
||||
|
||||
return $spanEl.wrap('<li>').parent()[0]; // xss-lint: disable=javascript-jquery-insertion
|
||||
};
|
||||
|
||||
return AsyncProcess.array(captions, process).done(function(list) {
|
||||
HtmlUtils.append(
|
||||
container,
|
||||
HtmlUtils.HTML(list)
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Initiates creating of captions and set their initial configuration.
|
||||
*
|
||||
* @param {array} start List of start times for the video.
|
||||
* @param {array} captions List of captions for the video.
|
||||
*
|
||||
*/
|
||||
renderCaption: function(start, captions) {
|
||||
var self = this;
|
||||
|
||||
var onRender = function() {
|
||||
self.addPaddings();
|
||||
// Enables or disables automatic scrolling of the captions when the
|
||||
// video is playing. This feature has to be disabled when tabbing
|
||||
// through them as it interferes with that action. Initially, have
|
||||
// this flag enabled as we assume mouse use. Then, if the first
|
||||
// caption (through forward tabbing) or the last caption (through
|
||||
// backwards tabbing) gets the focus, disable that feature.
|
||||
// Re-enable it if tabbing then cycles out of the the captions.
|
||||
self.autoScrolling = true;
|
||||
// Keeps track of where the focus is situated in the array of
|
||||
// captions. Used to implement the automatic scrolling behavior and
|
||||
// decide if the outline around a caption has to be hidden or shown
|
||||
// on a mouseenter or mouseleave. Initially, no caption has the
|
||||
// focus, set the index to -1.
|
||||
self.currentCaptionIndex = -1;
|
||||
// Used to track if the focus is coming from a click or tabbing. This
|
||||
// has to be known to decide if, when a caption gets the focus, an
|
||||
// outline has to be drawn (tabbing) or not (mouse click).
|
||||
self.isMouseFocus = false;
|
||||
self.rendered = true;
|
||||
self.state.el.addClass('is-captions-rendered');
|
||||
|
||||
self.subtitlesEl
|
||||
.attr('aria-label', gettext('Activating a link in this group will skip to the corresponding point in the video.')); // eslint-disable-line max-len
|
||||
|
||||
self.subtitlesEl.find('.transcript-title')
|
||||
.text(gettext('Video transcript'));
|
||||
|
||||
self.subtitlesEl.find('.transcript-start')
|
||||
.text(gettext('Start of transcript. Skip to the end.'))
|
||||
.attr('lang', $('html').attr('lang'));
|
||||
|
||||
self.subtitlesEl.find('.transcript-end')
|
||||
.text(gettext('End of transcript. Skip to the start.'))
|
||||
.attr('lang', $('html').attr('lang'));
|
||||
|
||||
self.container.find('.menu-container .instructions')
|
||||
.text(gettext('Press the UP arrow key to enter the language menu then use UP and DOWN arrow keys to navigate language options. Press ENTER to change to the selected language.')); // eslint-disable-line max-len
|
||||
};
|
||||
|
||||
this.rendered = false;
|
||||
this.subtitlesMenuEl.empty();
|
||||
this.setSubtitlesHeight();
|
||||
this.buildCaptions(this.subtitlesMenuEl, start, captions).done(onRender);
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Sets top and bottom spacing height and make sure they are taken
|
||||
* out of the tabbing order.
|
||||
*
|
||||
*/
|
||||
addPaddings: function() {
|
||||
var topSpacer = HtmlUtils.interpolateHtml(
|
||||
HtmlUtils.HTML([
|
||||
'<li class="spacing" style="height: {height}px">',
|
||||
'<a href="#transcript-end-{id}" id="transcript-start-{id}" class="transcript-start"></a>', // eslint-disable-line max-len, indent
|
||||
'</li>'
|
||||
].join('')),
|
||||
{
|
||||
id: this.state.id,
|
||||
height: this.topSpacingHeight()
|
||||
}
|
||||
);
|
||||
|
||||
var bottomSpacer = HtmlUtils.interpolateHtml(
|
||||
HtmlUtils.HTML([
|
||||
'<li class="spacing" style="height: {height}px">',
|
||||
'<a href="#transcript-start-{id}" id="transcript-end-{id}" class="transcript-end"></a>', // eslint-disable-line max-len, indent
|
||||
'</li>'
|
||||
].join('')),
|
||||
{
|
||||
id: this.state.id,
|
||||
height: this.bottomSpacingHeight()
|
||||
}
|
||||
);
|
||||
|
||||
HtmlUtils.prepend(
|
||||
this.subtitlesMenuEl,
|
||||
topSpacer
|
||||
);
|
||||
|
||||
HtmlUtils.append(
|
||||
this.subtitlesMenuEl,
|
||||
bottomSpacer
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc
|
||||
* On mouseOver: Hides the outline of a caption that has been tabbed to.
|
||||
* On mouseOut: Shows the outline of a caption that has been tabbed to.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
captionMouseOverOut: function(event) {
|
||||
var $caption = $(event.target),
|
||||
captionIndex = parseInt($caption.attr('data-index'), 10);
|
||||
|
||||
if (captionIndex === this.currentCaptionIndex) {
|
||||
if (event.type === 'mouseover') {
|
||||
$caption.removeClass('focused');
|
||||
} else { // mouseout
|
||||
$caption.addClass('focused');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Handles mousedown event on concrete caption.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
captionMouseDown: function(event) {
|
||||
var $caption = $(event.target);
|
||||
|
||||
this.isMouseFocus = true;
|
||||
this.autoScrolling = true;
|
||||
$caption.removeClass('focused');
|
||||
this.currentCaptionIndex = -1;
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Handles click event on concrete caption.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
captionClick: function(event) {
|
||||
this.seekPlayer(event);
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Handles focus event on concrete caption.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
captionFocus: function(event) {
|
||||
var $caption = $(event.target),
|
||||
container = $caption.parent(),
|
||||
captionIndex = parseInt($caption.attr('data-index'), 10);
|
||||
// If the focus comes from a mouse click, hide the outline, turn on
|
||||
// automatic scrolling and set currentCaptionIndex to point outside of
|
||||
// caption list (ie -1) to disable mouseenter, mouseleave behavior.
|
||||
if (this.isMouseFocus) {
|
||||
this.autoScrolling = true;
|
||||
container.removeClass('focused');
|
||||
this.currentCaptionIndex = -1;
|
||||
} else {
|
||||
// If the focus comes from tabbing, show the outline and turn off
|
||||
// automatic scrolling.
|
||||
|
||||
this.currentCaptionIndex = captionIndex;
|
||||
container.addClass('focused');
|
||||
// The second and second to last elements turn automatic scrolling
|
||||
// off again as it may have been enabled in captionBlur.
|
||||
if (
|
||||
captionIndex <= 1 ||
|
||||
captionIndex >= this.sjson.getSize() - 2
|
||||
) {
|
||||
this.autoScrolling = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Handles blur event on concrete caption.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
captionBlur: function(event) {
|
||||
var $caption = $(event.target),
|
||||
container = $caption.parent(),
|
||||
captionIndex = parseInt($caption.attr('data-index'), 10);
|
||||
|
||||
container.removeClass('focused');
|
||||
// If we are on first or last index, we have to turn automatic scroll
|
||||
// on again when losing focus. There is no way to know in what
|
||||
// direction we are tabbing. So we could be on the first element and
|
||||
// tabbing back out of the captions or on the last element and tabbing
|
||||
// forward out of the captions.
|
||||
if (captionIndex === 0 ||
|
||||
captionIndex === this.sjson.getSize() - 1) {
|
||||
this.autoScrolling = true;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Handles keydown event on concrete caption.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
captionKeyDown: function(event) {
|
||||
this.isMouseFocus = false;
|
||||
if (event.which === 13) { // Enter key
|
||||
this.seekPlayer(event);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Scrolls caption container to make active caption visible.
|
||||
*
|
||||
*/
|
||||
scrollCaption: function() {
|
||||
var el = this.subtitlesEl.find('.current:first');
|
||||
|
||||
// Automatic scrolling gets disabled if one of the captions has
|
||||
// received focus through tabbing.
|
||||
if (
|
||||
!this.frozen &&
|
||||
el.length &&
|
||||
this.autoScrolling
|
||||
) {
|
||||
this.subtitlesEl.scrollTo(
|
||||
el,
|
||||
{
|
||||
offset: -1 * this.calculateOffset(el)
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Updates flags on play
|
||||
*
|
||||
*/
|
||||
play: function() {
|
||||
var captions, startAndCaptions, start;
|
||||
if (this.loaded) {
|
||||
if (!this.rendered) {
|
||||
startAndCaptions = this.getBoundedCaptions();
|
||||
start = startAndCaptions.start;
|
||||
captions = startAndCaptions.captions;
|
||||
this.renderCaption(start, captions);
|
||||
}
|
||||
|
||||
this.playing = true;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Updates flags on pause
|
||||
*
|
||||
*/
|
||||
pause: function() {
|
||||
if (this.loaded) {
|
||||
this.playing = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Updates captions UI on paying.
|
||||
*
|
||||
* @param {number} time Time in seconds.
|
||||
*
|
||||
*/
|
||||
updatePlayTime: function(time) {
|
||||
var state = this.state,
|
||||
params, newIndex, times;
|
||||
|
||||
if (this.loaded) {
|
||||
if (state.isFlashMode()) {
|
||||
time = Time.convert(time, state.speed, '1.0');
|
||||
}
|
||||
|
||||
time = Math.round(time * 1000 + 100);
|
||||
times = this.getStartEndTimes();
|
||||
// if start and end times are defined, limit search.
|
||||
// else, use the entire list of video captions
|
||||
params = [time].concat(times);
|
||||
newIndex = this.sjson.search.apply(this.sjson, params);
|
||||
|
||||
if (
|
||||
typeof newIndex !== 'undefined' &&
|
||||
newIndex !== -1 &&
|
||||
this.currentIndex !== newIndex
|
||||
) {
|
||||
if (typeof this.currentIndex !== 'undefined') {
|
||||
this.subtitlesEl
|
||||
.find('li.current')
|
||||
.removeClass('current');
|
||||
}
|
||||
|
||||
this.subtitlesEl
|
||||
.find("span[data-index='" + newIndex + "']")
|
||||
.parent()
|
||||
.addClass('current');
|
||||
|
||||
this.currentIndex = newIndex;
|
||||
this.captionDisplayEl.text(this.subtitlesEl.find("span[data-index='" + newIndex + "']").text());
|
||||
this.scrollCaption();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Sends log to the server on caption seek.
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
seekPlayer: function(event) {
|
||||
var state = this.state,
|
||||
time = parseInt($(event.target).data('start'), 10);
|
||||
|
||||
if (state.isFlashMode()) {
|
||||
time = Math.round(Time.convert(time, '1.0', state.speed));
|
||||
}
|
||||
|
||||
state.trigger(
|
||||
'videoPlayer.onCaptionSeek',
|
||||
{
|
||||
type: 'onCaptionSeek',
|
||||
time: time / 1000
|
||||
}
|
||||
);
|
||||
|
||||
event.preventDefault();
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Calculates offset for paddings.
|
||||
*
|
||||
* @param {jquery element} element Top or bottom padding element.
|
||||
* @returns {number} Offset for the passed padding element.
|
||||
*
|
||||
*/
|
||||
calculateOffset: function(element) {
|
||||
return this.captionHeight() / 2 - element.height() / 2;
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Calculates offset for the top padding element.
|
||||
*
|
||||
* @returns {number} Offset for the passed top padding element.
|
||||
*
|
||||
*/
|
||||
topSpacingHeight: function() {
|
||||
return this.calculateOffset(
|
||||
this.subtitlesEl.find('li:not(.spacing)').first()
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Calculates offset for the bottom padding element.
|
||||
*
|
||||
* @returns {number} Offset for the passed bottom padding element.
|
||||
*
|
||||
*/
|
||||
bottomSpacingHeight: function() {
|
||||
return this.calculateOffset(
|
||||
this.subtitlesEl.find('li:not(.spacing)').last()
|
||||
);
|
||||
},
|
||||
|
||||
handleCaptioningCookie: function() {
|
||||
if ($.cookie('show_closed_captions') === 'true') {
|
||||
this.state.showClosedCaptions = true;
|
||||
this.showClosedCaptions();
|
||||
|
||||
// keep it going until turned off
|
||||
$.cookie('show_closed_captions', 'true', {
|
||||
expires: 3650,
|
||||
path: '/'
|
||||
});
|
||||
} else {
|
||||
this.hideClosedCaptions();
|
||||
}
|
||||
},
|
||||
|
||||
toggleClosedCaptions: function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.state.el.hasClass('has-captions')) {
|
||||
this.state.showClosedCaptions = false;
|
||||
this.updateCaptioningCookie(false);
|
||||
this.hideClosedCaptions();
|
||||
} else {
|
||||
this.state.showClosedCaptions = true;
|
||||
this.updateCaptioningCookie(true);
|
||||
this.showClosedCaptions();
|
||||
}
|
||||
},
|
||||
|
||||
showClosedCaptions: function() {
|
||||
var text = gettext('Hide closed captions');
|
||||
this.state.el.addClass('has-captions');
|
||||
|
||||
this.captionDisplayEl
|
||||
.show()
|
||||
.addClass('is-visible')
|
||||
.attr('lang', this.state.lang);
|
||||
|
||||
this.captionControlEl
|
||||
.addClass('is-active')
|
||||
.attr('title', text)
|
||||
.attr('aria-label', text);
|
||||
|
||||
if (this.subtitlesEl.find('.current').text()) {
|
||||
this.captionDisplayEl
|
||||
.text(this.subtitlesEl.find('.current').text());
|
||||
} else {
|
||||
this.captionDisplayEl
|
||||
.text(gettext('(Caption will be displayed when you start playing the video.)'));
|
||||
}
|
||||
|
||||
this.state.el.trigger('captions:show');
|
||||
},
|
||||
|
||||
hideClosedCaptions: function() {
|
||||
var text = gettext('Turn on closed captioning');
|
||||
this.state.el.removeClass('has-captions');
|
||||
|
||||
this.captionDisplayEl
|
||||
.hide()
|
||||
.removeClass('is-visible');
|
||||
|
||||
this.captionControlEl
|
||||
.removeClass('is-active')
|
||||
.attr('title', text)
|
||||
.attr('aria-label', text);
|
||||
|
||||
this.state.el.trigger('captions:hide');
|
||||
},
|
||||
|
||||
updateCaptioningCookie: function(method) {
|
||||
if (method) {
|
||||
$.cookie('show_closed_captions', 'true', {
|
||||
expires: 3650,
|
||||
path: '/'
|
||||
});
|
||||
} else {
|
||||
$.cookie('show_closed_captions', null, {
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* This runs when the video block is first rendered and sets the initial visibility
|
||||
* of the transcript panel based on the value of the 'show_transcript' cookie and/or
|
||||
* the block's showCaptions setting.
|
||||
*/
|
||||
setTranscriptVisibility: function() {
|
||||
var hideCaptionsOnRender = !this.state.config.showCaptions;
|
||||
|
||||
if ($.cookie('show_transcript') === 'true') {
|
||||
this.hideCaptionsOnLoad = false;
|
||||
// Keep it going until turned off.
|
||||
this.updateTranscriptCookie(true);
|
||||
} else if ($.cookie('show_transcript') === 'false') {
|
||||
hideCaptionsOnRender = true;
|
||||
this.hideCaptionsOnLoad = true;
|
||||
} else {
|
||||
this.hideCaptionsOnLoad = !this.state.config.showCaptions;
|
||||
}
|
||||
|
||||
if (hideCaptionsOnRender) {
|
||||
this.state.el.addClass('closed');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Shows/Hides transcript on click `transcript` button
|
||||
*
|
||||
* @param {jquery Event} event
|
||||
*
|
||||
*/
|
||||
toggleTranscript: function(event) {
|
||||
event.preventDefault();
|
||||
if (this.state.el.hasClass('closed')) {
|
||||
this.hideCaptions(false, true);
|
||||
this.updateTranscriptCookie(true);
|
||||
} else {
|
||||
this.hideCaptions(true, true);
|
||||
this.updateTranscriptCookie(false);
|
||||
}
|
||||
},
|
||||
|
||||
updateTranscriptCookie: function(showTranscript) {
|
||||
if (showTranscript) {
|
||||
$.cookie('show_transcript', 'true', {
|
||||
expires: 3650,
|
||||
path: '/'
|
||||
});
|
||||
} else {
|
||||
$.cookie('show_transcript', 'false', {
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
listenForDragDrop: function() {
|
||||
var captions = this.captionDisplayEl['0'];
|
||||
|
||||
if (typeof Draggabilly === 'function') {
|
||||
new Draggabilly(captions, {containment: true});
|
||||
} else {
|
||||
console.log('Closed captioning available but not draggable');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Shows/Hides the transcript panel.
|
||||
*
|
||||
* @param {boolean} hideCaptions if `true` hides the transcript panel,
|
||||
* otherwise - show.
|
||||
*/
|
||||
hideCaptions: function(hideCaptions, triggerEvent) {
|
||||
var transcriptControlEl = this.transcriptControlEl,
|
||||
state = this.state,
|
||||
text;
|
||||
|
||||
if (hideCaptions) {
|
||||
state.captionsHidden = true;
|
||||
state.el.addClass('closed');
|
||||
text = gettext('Turn on transcripts');
|
||||
if (triggerEvent) {
|
||||
this.state.el.trigger('transcript:hide');
|
||||
}
|
||||
|
||||
transcriptControlEl
|
||||
.removeClass('is-active')
|
||||
.attr('title', gettext(text))
|
||||
.attr('aria-label', text);
|
||||
} else {
|
||||
state.captionsHidden = false;
|
||||
state.el.removeClass('closed');
|
||||
this.scrollCaption();
|
||||
text = gettext('Turn off transcripts');
|
||||
if (triggerEvent) {
|
||||
this.state.el.trigger('transcript:show');
|
||||
}
|
||||
|
||||
transcriptControlEl
|
||||
.addClass('is-active')
|
||||
.attr('title', gettext(text))
|
||||
.attr('aria-label', text);
|
||||
}
|
||||
|
||||
if (state.resizer) {
|
||||
if (state.isFullScreen) {
|
||||
state.resizer.setMode('both');
|
||||
} else {
|
||||
state.resizer.alignByWidthOnly();
|
||||
}
|
||||
}
|
||||
|
||||
this.setSubtitlesHeight();
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Return the caption container height.
|
||||
*
|
||||
* @returns {number} event Height of the container in pixels.
|
||||
*
|
||||
*/
|
||||
captionHeight: function() {
|
||||
var state = this.state;
|
||||
if (state.isFullScreen) {
|
||||
return state.container.height() - state.videoFullScreen.height;
|
||||
} else {
|
||||
return state.container.height();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @desc Sets the height of the caption container element.
|
||||
*
|
||||
*/
|
||||
setSubtitlesHeight: function() {
|
||||
var height = 0,
|
||||
state = this.state;
|
||||
// on page load captionHidden = undefined
|
||||
if ((state.captionsHidden === undefined && this.hideCaptionsOnLoad) ||
|
||||
state.captionsHidden === true
|
||||
) {
|
||||
// In case of html5 autoshowing subtitles, we adjust height of
|
||||
// subs, by height of scrollbar.
|
||||
height = state.el.find('.video-controls').height() +
|
||||
0.5 * state.el.find('.slider').height();
|
||||
// Height of videoControl does not contain height of slider.
|
||||
// css is set to absolute, to avoid yanking when slider
|
||||
// autochanges its height.
|
||||
}
|
||||
|
||||
this.subtitlesEl.css({
|
||||
maxHeight: this.captionHeight() - height
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return VideoCaption;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
110
xmodule/js/src/video/10_commands.js
Normal file
110
xmodule/js/src/video/10_commands.js
Normal file
@@ -0,0 +1,110 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define('video/10_commands.js', [], function() {
|
||||
var VideoCommands, Command, playCommand, pauseCommand, togglePlaybackCommand,
|
||||
toggleMuteCommand, toggleFullScreenCommand, setSpeedCommand, skipCommand;
|
||||
/**
|
||||
* Video commands module.
|
||||
* @exports video/10_commands.js
|
||||
* @constructor
|
||||
* @param {Object} state The object containing the state of the video
|
||||
* @param {Object} i18n The object containing strings with translations.
|
||||
* @return {jquery Promise}
|
||||
*/
|
||||
VideoCommands = function(state, i18n) {
|
||||
if (!(this instanceof VideoCommands)) {
|
||||
return new VideoCommands(state, i18n);
|
||||
}
|
||||
|
||||
_.bindAll(this, 'destroy');
|
||||
this.state = state;
|
||||
this.state.videoCommands = this;
|
||||
this.i18n = i18n;
|
||||
this.commands = [];
|
||||
this.initialize();
|
||||
|
||||
return $.Deferred().resolve().promise();
|
||||
};
|
||||
|
||||
VideoCommands.prototype = {
|
||||
destroy: function() {
|
||||
this.state.el.off('destroy', this.destroy);
|
||||
delete this.state.videoCommands;
|
||||
},
|
||||
|
||||
/** Initializes the module. */
|
||||
initialize: function() {
|
||||
this.commands = this.getCommands();
|
||||
this.state.el.on('destroy', this.destroy);
|
||||
},
|
||||
|
||||
execute: function(command) {
|
||||
var args = [].slice.call(arguments, 1) || [];
|
||||
|
||||
if (_.has(this.commands, command)) {
|
||||
this.commands[command].execute.apply(this, [this.state].concat(args));
|
||||
} else {
|
||||
console.log('Command "' + command + '" is not available.');
|
||||
}
|
||||
},
|
||||
|
||||
getCommands: function() {
|
||||
var commands = {},
|
||||
commandsList = [
|
||||
playCommand, pauseCommand, togglePlaybackCommand,
|
||||
toggleMuteCommand, toggleFullScreenCommand, setSpeedCommand,
|
||||
skipCommand
|
||||
];
|
||||
|
||||
_.each(commandsList, function(command) {
|
||||
commands[command.name] = command;
|
||||
}, this);
|
||||
|
||||
return commands;
|
||||
}
|
||||
};
|
||||
|
||||
Command = function(name, execute) {
|
||||
this.name = name;
|
||||
this.execute = execute;
|
||||
};
|
||||
|
||||
playCommand = new Command('play', function(state) {
|
||||
state.videoPlayer.play();
|
||||
});
|
||||
|
||||
pauseCommand = new Command('pause', function(state) {
|
||||
state.videoPlayer.pause();
|
||||
});
|
||||
|
||||
togglePlaybackCommand = new Command('togglePlayback', function(state) {
|
||||
if (state.videoPlayer.isPlaying()) {
|
||||
pauseCommand.execute(state);
|
||||
} else {
|
||||
playCommand.execute(state);
|
||||
}
|
||||
});
|
||||
|
||||
toggleMuteCommand = new Command('toggleMute', function(state) {
|
||||
state.videoVolumeControl.toggleMute();
|
||||
});
|
||||
|
||||
toggleFullScreenCommand = new Command('toggleFullScreen', function(state) {
|
||||
state.videoFullScreen.toggle();
|
||||
});
|
||||
|
||||
setSpeedCommand = new Command('speed', function(state, speed) {
|
||||
state.videoSpeedControl.setSpeed(state.speedToString(speed));
|
||||
});
|
||||
|
||||
skipCommand = new Command('skip', function(state, doNotShowAgain) {
|
||||
if (doNotShowAgain) {
|
||||
state.videoBumper.skipAndDoNotShowAgain();
|
||||
} else {
|
||||
state.videoBumper.skip();
|
||||
}
|
||||
});
|
||||
|
||||
return VideoCommands;
|
||||
});
|
||||
}(RequireJS.define));
|
||||
194
xmodule/js/src/video/10_main.js
Normal file
194
xmodule/js/src/video/10_main.js
Normal file
@@ -0,0 +1,194 @@
|
||||
/* globals _ */
|
||||
/* RequireJS */
|
||||
(function(require, $) {
|
||||
'use strict';
|
||||
|
||||
// In the case when the Video constructor will be called before RequireJS finishes loading all of the Video
|
||||
// dependencies, we will have a mock function that will collect all the elements that must be initialized as
|
||||
// Video elements.
|
||||
//
|
||||
// Once RequireJS will load all of the necessary dependencies, main code will invoke the mock function with
|
||||
// the second parameter set to truthy value. This will trigger the actual Video constructor on all elements
|
||||
// that are stored in a temporary list.
|
||||
window.Video = (function() {
|
||||
// Temporary storage place for elements that must be initialized as Video elements.
|
||||
var tempCallStack = [];
|
||||
|
||||
return function(element, processTempCallStack) {
|
||||
// If mock function was called with second parameter set to truthy value, we invoke the real `window.Video`
|
||||
// on all the stored elements so far.
|
||||
if (processTempCallStack) {
|
||||
$.each(tempCallStack, function(index, el) {
|
||||
// By now, `window.Video` is the real constructor.
|
||||
window.Video(el);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// If normal call to `window.Video` constructor, store the element for later initializing.
|
||||
tempCallStack.push(element);
|
||||
|
||||
// Real Video constructor returns the `state` object. The mock function will return an empty object.
|
||||
return {};
|
||||
};
|
||||
}());
|
||||
|
||||
// Main module.
|
||||
require(
|
||||
/* End RequireJS */
|
||||
/* Webpack
|
||||
define(
|
||||
/* End Webpack */
|
||||
[
|
||||
'video/00_video_storage.js',
|
||||
'video/01_initialize.js',
|
||||
'video/025_focus_grabber.js',
|
||||
'video/035_video_accessible_menu.js',
|
||||
'video/04_video_control.js',
|
||||
'video/04_video_full_screen.js',
|
||||
'video/05_video_quality_control.js',
|
||||
'video/06_video_progress_slider.js',
|
||||
'video/07_video_volume_control.js',
|
||||
'video/08_video_speed_control.js',
|
||||
'video/08_video_auto_advance_control.js',
|
||||
'video/09_video_caption.js',
|
||||
'video/09_play_placeholder.js',
|
||||
'video/09_play_pause_control.js',
|
||||
'video/09_play_skip_control.js',
|
||||
'video/09_skip_control.js',
|
||||
'video/09_bumper.js',
|
||||
'video/09_save_state_plugin.js',
|
||||
'video/09_events_plugin.js',
|
||||
'video/09_events_bumper_plugin.js',
|
||||
'video/09_poster.js',
|
||||
'video/09_completion.js',
|
||||
'video/10_commands.js',
|
||||
'video/095_video_context_menu.js'
|
||||
],
|
||||
function(
|
||||
VideoStorage, initialize, FocusGrabber, VideoAccessibleMenu, VideoControl, VideoFullScreen,
|
||||
VideoQualityControl, VideoProgressSlider, VideoVolumeControl, VideoSpeedControl, VideoAutoAdvanceControl,
|
||||
VideoCaption, VideoPlayPlaceholder, VideoPlayPauseControl, VideoPlaySkipControl, VideoSkipControl,
|
||||
VideoBumper, VideoSaveStatePlugin, VideoEventsPlugin, VideoEventsBumperPlugin, VideoPoster,
|
||||
VideoCompletionHandler, VideoCommands, VideoContextMenu
|
||||
) {
|
||||
/* RequireJS */
|
||||
var youtubeXhr = null,
|
||||
oldVideo = window.Video;
|
||||
/* End RequireJS */
|
||||
/* Webpack
|
||||
var youtubeXhr = null;
|
||||
/* End Webpack */
|
||||
|
||||
window.Video = function(element) {
|
||||
var el = $(element).find('.video'),
|
||||
id = el.attr('id').replace(/video_/, ''),
|
||||
storage = VideoStorage('VideoState', id),
|
||||
bumperMetadata = el.data('bumper-metadata'),
|
||||
autoAdvanceEnabled = el.data('autoadvance-enabled') === 'True',
|
||||
mainVideoModules = [
|
||||
FocusGrabber, VideoControl, VideoPlayPlaceholder,
|
||||
VideoPlayPauseControl, VideoProgressSlider, VideoSpeedControl,
|
||||
VideoVolumeControl, VideoQualityControl, VideoFullScreen, VideoCaption, VideoCommands,
|
||||
VideoContextMenu, VideoSaveStatePlugin, VideoEventsPlugin, VideoCompletionHandler
|
||||
].concat(autoAdvanceEnabled ? [VideoAutoAdvanceControl] : []),
|
||||
bumperVideoModules = [VideoControl, VideoPlaySkipControl, VideoSkipControl,
|
||||
VideoVolumeControl, VideoCaption, VideoCommands, VideoSaveStatePlugin,
|
||||
VideoEventsBumperPlugin, VideoCompletionHandler],
|
||||
state = {
|
||||
el: el,
|
||||
id: id,
|
||||
metadata: el.data('metadata'),
|
||||
storage: storage,
|
||||
options: {},
|
||||
youtubeXhr: youtubeXhr,
|
||||
modules: mainVideoModules
|
||||
};
|
||||
|
||||
var getBumperState = function(metadata) {
|
||||
var bumperState = $.extend(true, {
|
||||
el: el,
|
||||
id: id,
|
||||
storage: storage,
|
||||
options: {},
|
||||
youtubeXhr: youtubeXhr
|
||||
}, {metadata: metadata});
|
||||
|
||||
bumperState.modules = bumperVideoModules;
|
||||
bumperState.options = {
|
||||
SaveStatePlugin: {events: ['language_menu:change']}
|
||||
};
|
||||
return bumperState;
|
||||
};
|
||||
|
||||
var player = function(innerState) {
|
||||
return function() {
|
||||
_.extend(innerState.metadata, {autoplay: true, focusFirstControl: true});
|
||||
initialize(innerState, element);
|
||||
};
|
||||
};
|
||||
var onSequenceChange;
|
||||
|
||||
VideoAccessibleMenu(el, {
|
||||
storage: storage,
|
||||
saveStateUrl: state.metadata.saveStateUrl
|
||||
});
|
||||
|
||||
if (bumperMetadata) {
|
||||
VideoPoster(el, {
|
||||
poster: el.data('poster'),
|
||||
onClick: _.once(function() {
|
||||
var mainVideoPlayer = player(state);
|
||||
var bumper, bumperState;
|
||||
if (storage.getItem('isBumperShown')) {
|
||||
mainVideoPlayer();
|
||||
} else {
|
||||
bumperState = getBumperState(bumperMetadata);
|
||||
bumper = new VideoBumper(player(bumperState), bumperState);
|
||||
state.bumperState = bumperState;
|
||||
bumper.getPromise().done(function() {
|
||||
delete state.bumperState;
|
||||
mainVideoPlayer();
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
} else {
|
||||
initialize(state, element);
|
||||
}
|
||||
|
||||
if (!youtubeXhr) {
|
||||
youtubeXhr = state.youtubeXhr;
|
||||
}
|
||||
|
||||
el.data('video-player-state', state);
|
||||
onSequenceChange = function() {
|
||||
if (state && state.videoPlayer) {
|
||||
state.videoPlayer.destroy();
|
||||
}
|
||||
$('.sequence').off('sequence:change', onSequenceChange);
|
||||
};
|
||||
$('.sequence').on('sequence:change', onSequenceChange);
|
||||
|
||||
// Because the 'state' object is only available inside this closure, we will also make it available to
|
||||
// the caller by returning it. This is necessary so that we can test Video with Jasmine.
|
||||
return state;
|
||||
};
|
||||
|
||||
window.Video.clearYoutubeXhr = function() {
|
||||
youtubeXhr = null;
|
||||
};
|
||||
|
||||
window.Video.loadYouTubeIFrameAPI = initialize.prototype.loadYouTubeIFrameAPI;
|
||||
|
||||
/* RequireJS */
|
||||
// Invoke the mock Video constructor so that the elements stored within it can be processed by the real
|
||||
// `window.Video` constructor.
|
||||
oldVideo(null, true);
|
||||
/* End RequireJS */
|
||||
}
|
||||
);
|
||||
/* RequireJS */
|
||||
}(window.RequireJS.require, window.jQuery));
|
||||
/* End RequireJS */
|
||||
Reference in New Issue
Block a user