refactor: Update and migrate Video Block JS files into xmodule/assets

- Move Video Block JS files from xmodule/js/src/video/ to xmodule/assets/video/public/js/ 
- Update JavaScript files from  RequireJS to ES6 import/export
- test: Enable and fix Karma Js tests for Video XBlock (#37351)

---------

Co-authored-by: salmannawaz <salman.nawaz@arbisoft.com>
This commit is contained in:
Muhammad Farhan Khan
2025-10-07 19:01:50 +05:00
committed by GitHub
parent e80317d814
commit 5c759f1e13
96 changed files with 10262 additions and 10321 deletions

View File

@@ -332,7 +332,11 @@ function getBaseConfig(config, useRequireJs) {
base: 'Firefox',
prefs: {
'app.update.auto': false,
'app.update.enabled': false
'app.update.enabled': false,
'media.autoplay.default': 0, // allow autoplay
'media.autoplay.blocking_policy': 0, // disable autoplay blocking
'media.autoplay.allow-extension-background-pages': true,
'media.autoplay.enabled.user-gestures-needed': false,
}
},
ChromeDocker: {

View File

@@ -15,7 +15,7 @@
"watch-sass": "scripts/watch_sass.sh",
"test": "npm run test-jest && npm run test-karma",
"test-jest": "jest",
"test-karma": "npm run test-karma-vanilla && npm run test-karma-require && echo 'WARNING: Skipped broken webpack tests. For details, see: https://github.com/openedx/edx-platform/issues/35956'",
"test-karma": "npm run test-karma-vanilla && npm run test-karma-require && npm run test-xmodule-webpack && echo 'WARNING: Skipped broken lms-webpack and cms-webpack tests. For details, see: https://github.com/openedx/edx-platform/issues/35956'",
"test-karma-vanilla": "npm run test-cms-vanilla && npm run test-xmodule-vanilla && npm run test-common-vanilla",
"test-karma-require": "npm run test-cms-require && npm run test-common-require",
"test-karma-webpack": "npm run test-cms-webpack && npm run test-lms-webpack && npm run test-xmodule-webpack",

View File

@@ -79,14 +79,13 @@ module.exports = {
'./xmodule/js/src/xmodule.js',
'./xmodule/js/src/sequence/edit.js'
],
VideoBlockDisplay: [
'./xmodule/js/src/xmodule.js',
'./xmodule/js/src/video/10_main.js'
],
VideoBlockEditor: [
'./xmodule/js/src/xmodule.js',
'./xmodule/js/src/tabs/tabs-aggregator.js'
],
VideoBlockDisplay: [
'./xmodule/assets/video/public/js/10_main.js'
],
WordCloudBlockDisplay: [
'./xmodule/js/src/xmodule.js',
'./xmodule/assets/word_cloud/src/js/word_cloud.js'

View File

@@ -505,15 +505,6 @@ module.exports = Merge.merge({
}
]
},
{
test: /xmodule\/js\/src\/video\/10_main.js/,
use: [
{
loader: 'imports-loader',
options: 'this=>window'
}
]
},
/*
* END BUILT-IN XBLOCK ASSETS WITH GLOBAL DEFINITIONS
***************************************************************************************************** */
@@ -680,9 +671,11 @@ module.exports = Merge.merge({
$: 'jQuery',
backbone: 'Backbone',
canvas: 'canvas',
fs: 'fs',
gettext: 'gettext',
jquery: 'jQuery',
logger: 'Logger',
path: 'path',
underscore: '_',
URI: 'URI',
XBlockToXModuleShim: 'XBlockToXModuleShim',

View File

@@ -0,0 +1,52 @@
'use strict';
/**
* Provides convenient way to process big amount of data without UI blocking.
*
* @param {array} list Array to process.
* @param {function} process Calls this function on each item in the list.
* @return {array} Returns a Promise object to observe when all actions of a
* certain type bound to the collection, queued or not, have finished.
*/
let AsyncProcess = {
array: function(list, process) {
if (!_.isArray(list)) {
return $.Deferred().reject().promise();
}
if (!_.isFunction(process) || !list.length) {
return $.Deferred().resolve(list).promise();
}
let MAX_DELAY = 50, // maximum amount of time that js code should be allowed to run continuously
dfd = $.Deferred();
let result = [];
let index = 0;
let len = list.length;
let getCurrentTime = function() {
return (new Date()).getTime();
};
let handler = function() {
let start = getCurrentTime();
do {
result[index] = process(list[index], index);
index++;
} while (index < len && getCurrentTime() - start < MAX_DELAY);
if (index < len) {
setTimeout(handler, 25);
} else {
dfd.resolve(result);
}
};
setTimeout(handler, 25);
return dfd.promise();
}
};
export default AsyncProcess;

View File

@@ -0,0 +1,81 @@
'use strict';
import _ from 'underscore';
/**
* Creates a new object with the specified prototype object and properties.
* @param {Object} o The object which should be the prototype of the
* newly-created object.
* @private
* @throws {TypeError, Error}
* @return {Object}
*/
let inherit = Object.create || (function() {
let F = function() {};
return function(o) {
if (arguments.length > 1) {
throw Error('Second argument not supported');
}
if (_.isNull(o) || _.isUndefined(o)) {
throw Error('Cannot set a null [[Prototype]]');
}
if (!_.isObject(o)) {
throw TypeError('Argument must be an object');
}
F.prototype = o;
return new F();
};
}());
/**
* Component module.
* @exports video/00_component.js
* @constructor
* @return {jquery Promise}
*/
let Component = function() {
if ($.isFunction(this.initialize)) {
// eslint-disable-next-line prefer-spread
return this.initialize.apply(this, arguments);
}
};
/**
* Returns new constructor that inherits form the current constructor.
* @static
* @param {Object} protoProps The object containing which will be added to
* the prototype.
* @return {Object}
*/
Component.extend = function(protoProps, staticProps) {
let Parent = this;
let Child = function() {
if ($.isFunction(this.initialize)) {
// eslint-disable-next-line prefer-spread
return this.initialize.apply(this, arguments);
}
};
// Inherit methods and properties from the Parent prototype.
Child.prototype = inherit(Parent.prototype);
Child.constructor = Parent;
// Provide access to parent's methods and properties
Child.__super__ = Parent.prototype;
// Extends inherited methods and properties by methods/properties
// passed as argument.
if (protoProps) {
$.extend(Child.prototype, protoProps);
}
// Inherit static methods and properties
$.extend(Child, Parent, staticProps);
return Child;
};
export default Component;

View File

@@ -0,0 +1,35 @@
'use strict';
/**
* i18n module.
* @exports video/00_i18n.js
* @return {object}
*/
let i18n = {
Play: gettext('Play'),
Pause: gettext('Pause'),
Mute: gettext('Mute'),
Unmute: gettext('Unmute'),
'Exit full browser': gettext('Exit full browser'),
'Fill browser': gettext('Fill browser'),
Speed: gettext('Speed'),
'Auto-advance': gettext('Auto-advance'),
Volume: gettext('Volume'),
// Translators: Volume level equals 0%.
Muted: gettext('Muted'),
// Translators: Volume level in range ]0,20]%
'Very low': gettext('Very low'),
// Translators: Volume level in range ]20,40]%
Low: gettext('Low'),
// Translators: Volume level in range ]40,60]%
Average: gettext('Average'),
// Translators: Volume level in range ]60,80]%
Loud: gettext('Loud'),
// Translators: Volume level in range ]80,99]%
'Very loud': gettext('Very loud'),
// Translators: Volume level equals 100%.
Maximum: gettext('Maximum')
};
export default i18n;

View File

@@ -0,0 +1,83 @@
'use strict';
/**
* Provides convenient way to work with iterable data.
* @exports video/00_iterator.js
* @constructor
* @param {array} list Array to be iterated.
*/
let Iterator = function(list) {
this.list = list;
this.index = 0;
this.size = this.list.length;
this.lastIndex = this.list.length - 1;
};
Iterator.prototype = {
/**
* Checks validity of provided index for the iterator.
* @access protected
* @param {numebr} index
* @return {boolean}
*/
_isValid: function(index) {
return _.isNumber(index) && index < this.size && index >= 0;
},
/**
* Returns next element.
* @param {number} [index] Updates current position.
* @return {any}
*/
next: function(index) {
if (!(this._isValid(index))) {
index = this.index;
}
this.index = (index >= this.lastIndex) ? 0 : index + 1;
return this.list[this.index];
},
/**
* Returns previous element.
* @param {number} [index] Updates current position.
* @return {any}
*/
prev: function(index) {
if (!(this._isValid(index))) {
index = this.index;
}
this.index = (index < 1) ? this.lastIndex : index - 1;
return this.list[this.index];
},
/**
* Returns last element in the list.
* @return {any}
*/
last: function() {
return this.list[this.lastIndex];
},
/**
* Returns first element in the list.
* @return {any}
*/
first: function() {
return this.list[0];
},
/**
* Returns `true` if current position is last for the iterator.
* @return {boolean}
*/
isEnd: function() {
return this.index === this.lastIndex;
}
};
export default Iterator;

View File

@@ -0,0 +1,236 @@
'use strict';
import _ from 'underscore';
let Resizer = function(params) {
let defaults = {
container: window,
element: null,
containerRatio: null,
elementRatio: null
},
callbacksList = [],
delta = {
height: 0,
width: 0
},
module = {};
let mode = null,
config;
// eslint-disable-next-line no-shadow
let initialize = function(params) {
if (!config) {
config = defaults;
}
config = $.extend(true, {}, config, params);
if (!config.element) {
console.log(
'Required parameter `element` is not passed.'
);
}
return module;
};
let getData = function() {
let $container = $(config.container),
containerWidth = $container.width() + delta.width,
containerHeight = $container.height() + delta.height;
let containerRatio = config.containerRatio;
let $element = $(config.element);
let elementRatio = config.elementRatio;
if (!containerRatio) {
containerRatio = containerWidth / containerHeight;
}
if (!elementRatio) {
elementRatio = $element.width() / $element.height();
}
return {
containerWidth: containerWidth,
containerHeight: containerHeight,
containerRatio: containerRatio,
element: $element,
elementRatio: elementRatio
};
};
let align = function() {
let data = getData();
switch (mode) {
case 'height':
alignByHeightOnly();
break;
case 'width':
alignByWidthOnly();
break;
default:
if (data.containerRatio >= data.elementRatio) {
alignByHeightOnly();
} else {
alignByWidthOnly();
}
break;
}
fireCallbacks();
return module;
};
let alignByWidthOnly = function() {
let data = getData(),
height = data.containerWidth / data.elementRatio;
data.element.css({
height: height,
width: data.containerWidth,
top: 0.5 * (data.containerHeight - height),
left: 0
});
return module;
};
let alignByHeightOnly = function() {
let data = getData(),
width = data.containerHeight * data.elementRatio;
data.element.css({
height: data.containerHeight,
width: data.containerHeight * data.elementRatio,
top: 0,
left: 0.5 * (data.containerWidth - width)
});
return module;
};
let setMode = function(param) {
if (_.isString(param)) {
mode = param;
align();
}
return module;
};
let setElement = function(element) {
config.element = element;
return module;
};
let addCallback = function(func) {
if ($.isFunction(func)) {
callbacksList.push(func);
} else {
console.error('[Video info]: TypeError: Argument is not a function.');
}
return module;
};
let addOnceCallback = function(func) {
if ($.isFunction(func)) {
let decorator = function() {
func();
removeCallback(func);
};
addCallback(decorator);
} else {
console.error('TypeError: Argument is not a function.');
}
return module;
};
let fireCallbacks = function() {
$.each(callbacksList, function(index, callback) {
callback();
});
};
let removeCallbacks = function() {
callbacksList.length = 0;
return module;
};
let removeCallback = function(func) {
let index = $.inArray(func, callbacksList);
if (index !== -1) {
return callbacksList.splice(index, 1);
}
};
let resetDelta = function() {
// eslint-disable-next-line no-multi-assign
delta.height = delta.width = 0;
return module;
};
let addDelta = function(value, side) {
if (_.isNumber(value) && _.isNumber(delta[side])) {
delta[side] += value;
}
return module;
};
let substractDelta = function(value, side) {
if (_.isNumber(value) && _.isNumber(delta[side])) {
delta[side] -= value;
}
return module;
};
let destroy = function() {
let data = getData();
data.element.css({
height: '', width: '', top: '', left: ''
});
removeCallbacks();
resetDelta();
mode = null;
};
initialize.apply(module, arguments);
return $.extend(true, module, {
align: align,
alignByWidthOnly: alignByWidthOnly,
alignByHeightOnly: alignByHeightOnly,
destroy: destroy,
setParams: initialize,
setMode: setMode,
setElement: setElement,
callbacks: {
add: addCallback,
once: addOnceCallback,
remove: removeCallback,
removeAll: removeCallbacks
},
delta: {
add: addDelta,
substract: substractDelta,
reset: resetDelta
}
});
};
export default Resizer;

View File

@@ -0,0 +1,108 @@
'use strict';
let Sjson = function(data) {
let sjson = {
start: data.start.concat(),
text: data.text.concat()
},
module = {};
let getter = function(propertyName) {
return function() {
return sjson[propertyName];
};
};
let getStartTimes = getter('start');
let getCaptions = getter('text');
let size = function() {
return sjson.text.length;
};
function search(time, startTime, endTime) {
let start = getStartTimes(),
max = size() - 1,
min = 0,
results,
index;
// if we specify a start and end time to search,
// search the filtered list of captions in between
// the start / end times.
// Else, search the unfiltered list.
if (typeof startTime !== 'undefined'
&& typeof endTime !== 'undefined') {
results = filter(startTime, endTime);
start = results.start;
max = results.captions.length - 1;
} else {
start = getStartTimes();
}
while (min < max) {
index = Math.ceil((max + min) / 2);
if (time < start[index]) {
max = index - 1;
}
if (time >= start[index]) {
min = index;
}
}
return min;
}
function filter(start, end) {
/* filters captions that occur between inputs
* `start` and `end`. Start and end should
* be Numbers (doubles) corresponding to the
* number of seconds elapsed since the beginning
* of the video.
*
* Returns an object with properties
* "start" and "captions" representing
* parallel arrays of start times and
* their corresponding captions.
*/
let filteredTimes = [];
let filteredCaptions = [];
let startTimes = getStartTimes();
let captions = getCaptions();
if (startTimes.length !== captions.length) {
console.warn('video caption and start time arrays do not match in length');
}
// if end is null, then it's been set to
// some erroneous value, so filter using the
// entire array as long as it's not empty
if (end === null && startTimes.length) {
end = startTimes[startTimes.length - 1];
}
_.filter(startTimes, function(currentStartTime, i) {
if (currentStartTime >= start && currentStartTime <= end) {
filteredTimes.push(currentStartTime);
filteredCaptions.push(captions[i]);
}
});
return {
start: filteredTimes,
captions: filteredCaptions
};
}
return {
getCaptions: getCaptions,
getStartTimes: getStartTimes,
getSize: size,
filter: filter,
search: search
};
};
export default Sjson;

View File

@@ -0,0 +1,96 @@
'use strict';
/**
* Provides convenient way to store key value pairs.
*
* @param {string} namespace Namespace that is used to store data.
* @return {object} VideoStorage API.
*/
let VideoStorage = function(namespace, id) {
/**
* Adds new value to the storage or rewrites existent.
*
* @param {string} name Identifier of the data.
* @param {any} value Data to store.
* @param {boolean} instanceSpecific Data with this flag will be added
* to instance specific storage.
*/
let setItem = function(name, value, instanceSpecific) {
if (name) {
if (instanceSpecific) {
window[namespace][id][name] = value;
} else {
window[namespace][name] = value;
}
}
};
/**
* Returns the current value associated with the given name.
*
* @param {string} name Identifier of the data.
* @param {boolean} instanceSpecific Data with this flag will be added
* to instance specific storage.
* @return {any} The current value associated with the given name.
* If the given key does not exist in the list
* associated with the object then this method must return null.
*/
let getItem = function(name, instanceSpecific) {
if (instanceSpecific) {
return window[namespace][id][name];
} else {
return window[namespace][name];
}
};
/**
* Removes the current value associated with the given name.
*
* @param {string} name Identifier of the data.
* @param {boolean} instanceSpecific Data with this flag will be added
* to instance specific storage.
*/
let removeItem = function(name, instanceSpecific) {
if (instanceSpecific) {
delete window[namespace][id][name];
} else {
delete window[namespace][name];
}
};
/**
* Empties the storage.
*
*/
let clear = function() {
window[namespace] = {};
window[namespace][id] = {};
};
/**
* Initializes the module: creates a storage with proper namespace.
*
* @private
*/
(function initialize() {
if (!namespace) {
namespace = 'VideoStorage';
}
if (!id) {
// Generate random alpha-numeric string.
id = Math.random().toString(36).slice(2);
}
window[namespace] = window[namespace] || {};
window[namespace][id] = window[namespace][id] || {};
}());
return {
clear: clear,
getItem: getItem,
removeItem: removeItem,
setItem: setItem
};
};
export default VideoStorage;

View 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
*/
import VideoPlayer from './03_video_player.js';
import i18n from './00_i18n.js';
import _ from 'underscore';
import moment from 'moment';
/**
* @function
*
* Initialize module exports this function.
*
* @param {object} state The object containg the state of the video player.
* All other modules, their parameters, public variables, etc. are
* available via this object.
* @param {DOM element} element Container of the entire Video DOM element.
*/
let Initialize = function(state, element) {
_makeFunctionsPublic(state);
state.initialize(element)
.done(function() {
if (state.isYoutubeType()) {
state.parseSpeed();
}
// On iPhones and iPods native controls are used.
if (/iP(hone|od)/i.test(state.isTouch[0])) {
_hideWaitPlaceholder(state);
state.el.trigger('initialize', arguments);
return false;
}
_initializeModules(state, i18n)
.done(function() {
// On iPad ready state occurs just after start playing.
// We hide controls before video starts playing.
if (/iPad|Android/i.test(state.isTouch[0])) {
state.el.on('play', _.once(function() {
state.trigger('videoControl.show', null);
}));
} else {
// On PC show controls immediately.
state.trigger('videoControl.show', null);
}
_hideWaitPlaceholder(state);
state.el.trigger('initialize', arguments);
});
});
};
/* eslint-disable no-use-before-define */
let methodsDict = {
bindTo: bindTo,
fetchMetadata: fetchMetadata,
getCurrentLanguage: getCurrentLanguage,
getDuration: getDuration,
getPlayerMode: getPlayerMode,
getVideoMetadata: getVideoMetadata,
initialize: initialize,
isHtml5Mode: isHtml5Mode,
isFlashMode: isFlashMode,
isYoutubeType: isYoutubeType,
parseSpeed: parseSpeed,
parseYoutubeStreams: parseYoutubeStreams,
setPlayerMode: setPlayerMode,
setSpeed: setSpeed,
setAutoAdvance: setAutoAdvance,
speedToString: speedToString,
trigger: trigger,
youtubeId: youtubeId,
loadHtmlPlayer: loadHtmlPlayer,
loadYoutubePlayer: loadYoutubePlayer,
loadYouTubeIFrameAPI: loadYouTubeIFrameAPI
};
/* eslint-enable no-use-before-define */
let _youtubeApiDeferred = null;
let _oldOnYouTubeIframeAPIReady;
Initialize.prototype = methodsDict;
export default Initialize;
// ***************************************************************
// Private functions start here. Private functions start with underscore.
// ***************************************************************
/**
* @function _makeFunctionsPublic
*
* Functions which will be accessible via 'state' object. When called,
* these functions will get the 'state'
* object as a context.
*
* @param {object} state The object containg the state (properties,
* methods, modules) of the Video player.
*/
function _makeFunctionsPublic(state) {
bindTo(methodsDict, state, state);
}
// function _renderElements(state)
//
// Create any necessary DOM elements, attach them, and set their
// initial configuration. Also make the created DOM elements available
// via the 'state' object. Much easier to work this way - you don't
// have to do repeated jQuery element selects.
function _renderElements(state) {
// Launch embedding of actual video content, or set it up so that it
// will be done as soon as the appropriate video player (YouTube or
// stand-alone HTML5) is loaded, and can handle embedding.
//
// Note that the loading of stand alone HTML5 player API is handled by
// Require JS. At the time when we reach this code, the stand alone
// HTML5 player is already loaded, so no further testing in that case
// is required.
let video;
let onYTApiReady;
let setupOnYouTubeIframeAPIReady;
if (state.videoType === 'youtube') {
state.youtubeApiAvailable = false;
onYTApiReady = function() {
console.log('[Video info]: YouTube API is available and is loaded.');
if (state.htmlPlayerLoaded) { return; }
console.log('[Video info]: Starting YouTube player.');
video = VideoPlayer(state);
state.modules.push(video);
state.__dfd__.resolve();
state.youtubeApiAvailable = true;
};
if (window.YT) {
// If we have a Deferred object responsible for calling OnYouTubeIframeAPIReady
// callbacks, make sure that they have all been called by trying to resolve the
// Deferred object. Upon resolving, all the OnYouTubeIframeAPIReady will be
// called. If the object has been already resolved, the callbacks will not
// be called a second time.
if (_youtubeApiDeferred) {
_youtubeApiDeferred.resolve();
}
window.YT.ready(onYTApiReady);
} else {
// There is only one global variable window.onYouTubeIframeAPIReady which
// is supposed to be a function that will be called by the YouTube API
// when it finished initializing. This function will update this global function
// so that it resolves our Deferred object, which will call all of the
// OnYouTubeIframeAPIReady callbacks.
//
// If this global function is already defined, we store it first, and make
// sure that it gets executed when our Deferred object is resolved.
setupOnYouTubeIframeAPIReady = function() {
_oldOnYouTubeIframeAPIReady = window.onYouTubeIframeAPIReady || undefined;
window.onYouTubeIframeAPIReady = function() {
_youtubeApiDeferred.resolve();
};
window.onYouTubeIframeAPIReady.done = _youtubeApiDeferred.done;
if (_oldOnYouTubeIframeAPIReady) {
window.onYouTubeIframeAPIReady.done(_oldOnYouTubeIframeAPIReady);
}
};
// If a Deferred object hasn't been created yet, create one now. It will
// be responsible for calling OnYouTubeIframeAPIReady callbacks once the
// YouTube API loads. After creating the Deferred object, load the YouTube
// API.
if (!_youtubeApiDeferred) {
_youtubeApiDeferred = $.Deferred();
setupOnYouTubeIframeAPIReady();
} else if (!window.onYouTubeIframeAPIReady || !window.onYouTubeIframeAPIReady.done) {
// The Deferred object could have been already defined in a previous
// initialization of the video module. However, since then the global variable
// window.onYouTubeIframeAPIReady could have been overwritten. If so,
// we should set it up again.
setupOnYouTubeIframeAPIReady();
}
// Attach a callback to our Deferred object to be called once the
// YouTube API loads.
window.onYouTubeIframeAPIReady.done(function() {
window.YT.ready(onYTApiReady);
});
}
} else {
video = VideoPlayer(state);
state.modules.push(video);
state.__dfd__.resolve();
state.htmlPlayerLoaded = true;
}
}
function _waitForYoutubeApi(state) {
console.log('[Video info]: Starting to wait for YouTube API to load.');
window.setTimeout(function() {
// If YouTube API will load OK, it will run `onYouTubeIframeAPIReady`
// callback, which will set `state.youtubeApiAvailable` to `true`.
// If something goes wrong at this stage, `state.youtubeApiAvailable` is
// `false`.
if (!state.youtubeApiAvailable) {
console.log('[Video info]: YouTube API is not available.');
if (!state.htmlPlayerLoaded) {
state.loadHtmlPlayer();
}
}
state.el.trigger('youtube_availability', [state.youtubeApiAvailable]);
}, state.config.ytTestTimeout);
}
function loadYouTubeIFrameAPI(scriptTag) {
let firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(scriptTag, firstScriptTag);
}
// function _parseYouTubeIDs(state)
// The function parse YouTube stream ID's.
// @return
// false: We don't have YouTube video IDs to work with; most likely
// we have HTML5 video sources.
// true: Parsing of YouTube video IDs went OK, and we can proceed
// onwards to play YouTube videos.
function _parseYouTubeIDs(state) {
if (state.parseYoutubeStreams(state.config.streams)) {
state.videoType = 'youtube';
return true;
}
console.log(
'[Video info]: Youtube Video IDs are incorrect or absent.'
);
return false;
}
/**
* Extract HLS video URLs from available video URLs.
*
* @param {object} state The object contaning the state (properties, methods, modules) of the Video player.
* @returns Array of available HLS video source urls.
*/
function extractHLSVideoSources(state) {
return _.filter(state.config.sources, function(source) {
return /\.m3u8(\?.*)?$/.test(source);
});
}
// function _prepareHTML5Video(state)
// The function prepare HTML5 video, parse HTML5
// video sources etc.
function _prepareHTML5Video(state) {
state.speeds = ['0.75', '1.0', '1.25', '1.50', '2.0'];
// If none of the supported video formats can be played and there is no
// short-hand video links, than hide the spinner and show error message.
if (!state.config.sources.length) {
_hideWaitPlaceholder(state);
state.el
.find('.video-player div')
.addClass('hidden');
state.el
.find('.video-player .video-error')
.removeClass('is-hidden');
return false;
}
state.videoType = 'html5';
if (!_.keys(state.config.transcriptLanguages).length) {
state.config.showCaptions = false;
}
state.setSpeed(state.speed);
return true;
}
function _hideWaitPlaceholder(state) {
state.el
.addClass('is-initialized')
.find('.spinner')
.attr({
'aria-hidden': 'true',
tabindex: -1
});
}
function _setConfigurations(state) {
state.setPlayerMode(state.config.mode);
// Possible value are: 'visible', 'hiding', and 'invisible'.
state.controlState = 'visible';
state.controlHideTimeout = null;
state.captionState = 'invisible';
state.captionHideTimeout = null;
state.HLSVideoSources = extractHLSVideoSources(state);
}
// eslint-disable-next-line no-shadow
function _initializeModules(state, i18n) {
let dfd = $.Deferred(),
modulesList = $.map(state.modules, function(module) {
let options = state.options[module.moduleName] || {};
if (_.isFunction(module)) {
return module(state, i18n, options);
} else if ($.isPlainObject(module)) {
return module;
}
});
$.when.apply(null, modulesList)
.done(dfd.resolve);
return dfd.promise();
}
function _getConfiguration(data, storage) {
let isBoolean = function(value) {
let regExp = /^true$/i;
return regExp.test(value.toString());
},
// List of keys that will be extracted form the configuration.
extractKeys = [],
// Compatibility keys used to change names of some parameters in
// the final configuration.
compatKeys = {
start: 'startTime',
end: 'endTime'
},
// Conversions used to pre-process some configuration data.
conversions = {
showCaptions: isBoolean,
autoplay: isBoolean,
autohideHtml5: isBoolean,
autoAdvance: function(value) {
let shouldAutoAdvance = storage.getItem('auto_advance');
if (_.isUndefined(shouldAutoAdvance)) {
return isBoolean(value) || false;
} else {
return shouldAutoAdvance;
}
},
savedVideoPosition: function(value) {
return storage.getItem('savedVideoPosition', true)
|| Number(value)
|| 0;
},
speed: function(value) {
return storage.getItem('speed', true) || value;
},
generalSpeed: function(value) {
return storage.getItem('general_speed')
|| value
|| '1.0';
},
transcriptLanguage: function(value) {
return storage.getItem('language')
|| value
|| 'en';
},
ytTestTimeout: function(value) {
value = parseInt(value, 10);
if (!isFinite(value)) {
value = 1500;
}
return value;
},
startTime: function(value) {
value = parseInt(value, 10);
if (!isFinite(value) || value < 0) {
return 0;
}
return value;
},
endTime: function(value) {
value = parseInt(value, 10);
if (!isFinite(value) || value === 0) {
return null;
}
return value;
}
},
config = {};
data = _.extend({
startTime: 0,
endTime: null,
sub: '',
streams: ''
}, data);
$.each(data, function(option, value) {
// Extract option that is in `extractKeys`.
if ($.inArray(option, extractKeys) !== -1) {
return;
}
// Change option name to key that is in `compatKeys`.
if (compatKeys[option]) {
option = compatKeys[option];
}
// Pre-process data.
if (conversions[option]) {
if (_.isFunction(conversions[option])) {
value = conversions[option].call(this, value);
} else {
throw new TypeError(option + ' is not a function.');
}
}
config[option] = value;
});
return config;
}
// ***************************************************************
// Public functions start here.
// These are available via the 'state' object. Their context ('this'
// keyword) is the 'state' object. The magic private function that makes
// them available and sets up their context is makeFunctionsPublic().
// ***************************************************************
// function bindTo(methodsDict, obj, context, rewrite)
// Creates a new function with specific context and assigns it to the provided
// object.
// eslint-disable-next-line no-shadow
function bindTo(methodsDict, obj, context, rewrite) {
$.each(methodsDict, function(name, method) {
if (_.isFunction(method)) {
if (_.isUndefined(rewrite)) {
rewrite = true;
}
if (_.isUndefined(obj[name]) || rewrite) {
obj[name] = _.bind(method, context);
}
}
});
}
function loadYoutubePlayer() {
if (this.htmlPlayerLoaded) { return; }
console.log(
'[Video info]: Fetch metadata for YouTube video.'
);
this.fetchMetadata();
this.parseSpeed();
}
function loadHtmlPlayer() {
// When the youtube link doesn't work for any reason
// (for example, firewall) any
// alternate sources should automatically play.
if (!_prepareHTML5Video(this)) {
console.log(
'[Video info]: Continue loading '
+ 'YouTube video.'
);
// Non-YouTube sources were not found either.
this.el.find('.video-player div')
.removeClass('hidden');
this.el.find('.video-player .video-error')
.addClass('is-hidden');
// If in reality the timeout was to short, try to
// continue loading the YouTube video anyways.
this.loadYoutubePlayer();
} else {
console.log(
'[Video info]: Start HTML5 player.'
);
// In-browser HTML5 player does not support quality
// control.
this.el.find('.quality_control').hide();
_renderElements(this);
}
}
// function initialize(element)
// The function set initial configuration and preparation.
function initialize(element) {
let self = this,
el = this.el,
id = this.id,
container = el.find('.video-wrapper'),
__dfd__ = $.Deferred(),
isTouch = onTouchBasedDevice() || '';
if (isTouch) {
el.addClass('is-touch');
}
$.extend(this, {
__dfd__: __dfd__,
container: container,
isFullScreen: false,
isTouch: isTouch
});
console.log('[Video info]: Initializing video with id "%s".', id);
// We store all settings passed to us by the server in one place. These
// are "read only", so don't modify them. All variable content lives in
// 'state' object.
// jQuery .data() return object with keys in lower camelCase format.
this.config = $.extend({}, _getConfiguration(this.metadata, this.storage), {
element: element,
fadeOutTimeout: 1400,
captionsFreezeTime: 10000,
mode: $.cookie('edX_video_player_mode'),
// Available HD qualities will only be accessible once the video has
// been played once, via player.getAvailableQualityLevels.
availableHDQualities: []
});
if (this.config.endTime < this.config.startTime) {
this.config.endTime = null;
}
this.lang = this.config.transcriptLanguage;
this.speed = this.speedToString(
this.config.speed || this.config.generalSpeed
);
this.auto_advance = this.config.autoAdvance;
this.htmlPlayerLoaded = false;
this.duration = this.metadata.duration;
_setConfigurations(this);
// If `prioritizeHls` is set to true than `hls` is the primary playback
if (this.config.prioritizeHls || !(_parseYouTubeIDs(this))) {
// If we do not have YouTube ID's, try parsing HTML5 video sources.
if (!_prepareHTML5Video(this)) {
__dfd__.reject();
// Non-YouTube sources were not found either.
return __dfd__.promise();
}
console.log('[Video info]: Start player in HTML5 mode.');
_renderElements(this);
} else {
_renderElements(this);
_waitForYoutubeApi(this);
let scriptTag = document.createElement('script');
scriptTag.src = this.config.ytApiUrl;
scriptTag.async = true;
$(scriptTag).on('load', function() {
self.loadYoutubePlayer();
});
$(scriptTag).on('error', function() {
console.log(
'[Video info]: YouTube returned an error for '
+ 'video with id "' + self.id + '".'
);
// If the video is already loaded in `_waitForYoutubeApi` by the
// time we get here, then we shouldn't load it again.
if (!self.htmlPlayerLoaded) {
self.loadHtmlPlayer();
}
});
window.Video.loadYouTubeIFrameAPI(scriptTag);
}
return __dfd__.promise();
}
// function parseYoutubeStreams(state, youtubeStreams)
//
// Take a string in the form:
// "iCawTYPtehk:0.75,KgpclqP-LBA:1.0,9-2670d5nvU:1.5"
// parse it, and make it available via the 'state' object. If we are
// not given a string, or it's length is zero, then we return false.
//
// @return
// false: We don't have YouTube video IDs to work with; most likely
// we have HTML5 video sources.
// true: Parsing of YouTube video IDs went OK, and we can proceed
// onwards to play YouTube videos.
function parseYoutubeStreams(youtubeStreams) {
if (_.isUndefined(youtubeStreams) || !youtubeStreams.length) {
return false;
}
this.videos = {};
_.each(youtubeStreams.split(/,/), function(video) {
let speed;
video = video.split(/:/);
speed = this.speedToString(video[0]);
this.videos[speed] = video[1];
}, this);
return _.isString(this.videos['1.0']);
}
// function fetchMetadata()
//
// When dealing with YouTube videos, we must fetch meta data that has
// certain key facts not available while the video is loading. For
// example the length of the video can be determined from the meta
// data.
function fetchMetadata() {
let self = this,
metadataXHRs = [];
this.metadata = {};
metadataXHRs = _.map(this.videos, function(url, speed) {
return self.getVideoMetadata(url, function(data) {
if (data.items.length > 0) {
let metaDataItem = data.items[0];
self.metadata[metaDataItem.id] = metaDataItem.contentDetails;
}
});
});
$.when.apply(this, metadataXHRs).done(function() {
self.el.trigger('metadata_received');
// Not only do we trigger the "metadata_received" event, we also
// set a flag to notify that metadata has been received. This
// allows for code that will miss the "metadata_received" event
// to know that metadata has been received. This is important in
// cases when some code will subscribe to the "metadata_received"
// event after it has been triggered.
self.youtubeMetadataReceived = true;
});
}
// function parseSpeed()
//
// Create a separate array of available speeds.
function parseSpeed() {
this.speeds = _.keys(this.videos).sort();
}
function setSpeed(newSpeed) {
// Possible speeds for each player type.
// HTML5 = [0.75, 1, 1.25, 1.5, 2]
// Youtube Flash = [0.75, 1, 1.25, 1.5]
// Youtube HTML5 = [0.25, 0.5, 1, 1.5, 2]
let map = {
0.25: '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash
'0.50': '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash
0.75: '0.50', // HTML5 or Youtube Flash -> Youtube HTML5
1.25: '1.50', // HTML5 or Youtube Flash -> Youtube HTML5
2.0: '1.50' // HTML5 or Youtube HTML5 -> Youtube Flash
};
if (_.contains(this.speeds, newSpeed)) {
this.speed = newSpeed;
} else {
newSpeed = map[newSpeed];
this.speed = _.contains(this.speeds, newSpeed) ? newSpeed : '1.0';
}
this.speed = parseFloat(this.speed);
}
function setAutoAdvance(enabled) {
this.auto_advance = enabled;
}
function getVideoMetadata(url, callback) {
let youTubeEndpoint;
if (!(_.isString(url))) {
url = this.videos['1.0'] || '';
}
// Will hit the API URL to get the youtube video metadata.
youTubeEndpoint = this.config.ytMetadataEndpoint; // The new runtime supports anonymous users
// and uses an XBlock handler to get YouTube metadata
if (!youTubeEndpoint) {
// The old runtime has a full/separate LMS API for getting YouTube metadata, but it doesn't
// support anonymous users nor videos that play in a sandboxed iframe.
youTubeEndpoint = [this.config.lmsRootURL, '/courses/yt_video_metadata', '?id=', url].join('');
}
return $.ajax({
url: youTubeEndpoint,
success: _.isFunction(callback) ? callback : null,
error: function() {
console.warn(
'Unable to get youtube video metadata. Some video metadata may be unavailable.'
);
},
notifyOnError: false
});
}
function youtubeId(speed) {
let currentSpeed = this.isFlashMode() ? this.speed : '1.0';
return this.videos[speed]
|| this.videos[currentSpeed]
|| this.videos['1.0'];
}
function getDuration() {
try {
let safeMoment = typeof moment !== 'undefined' ? moment : window.moment;
return safeMoment.duration(this.metadata[this.youtubeId()].duration, safeMoment.ISO_8601).asSeconds();
} catch (err) {
return _.result(this.metadata[this.youtubeId('1.0')], 'duration') || 0;
}
}
/**
* Sets player mode.
*
* @param {string} mode Mode to set for the video player if it is supported.
* Otherwise, `html5` is used by default.
*/
function setPlayerMode(mode) {
let supportedModes = ['html5', 'flash'];
mode = _.contains(supportedModes, mode) ? mode : 'html5';
this.currentPlayerMode = mode;
}
/**
* Returns current player mode.
*
* @return {string} Returns string that describes player mode
*/
function getPlayerMode() {
return this.currentPlayerMode;
}
/**
* Checks if current player mode is Flash.
*
* @return {boolean} Returns `true` if current mode is `flash`, otherwise
* it returns `false`
*/
function isFlashMode() {
return this.getPlayerMode() === 'flash';
}
/**
* Checks if current player mode is Html5.
*
* @return {boolean} Returns `true` if current mode is `html5`, otherwise
* it returns `false`
*/
function isHtml5Mode() {
return this.getPlayerMode() === 'html5';
}
function isYoutubeType() {
return this.videoType === 'youtube';
}
function speedToString(speed) {
return parseFloat(speed).toFixed(2).replace(/\.00$/, '.0');
}
function getCurrentLanguage() {
let keys = _.keys(this.config.transcriptLanguages);
if (keys.length) {
if (!_.contains(keys, this.lang)) {
if (_.contains(keys, 'en')) {
this.lang = 'en';
} else {
this.lang = keys.pop();
}
}
} else {
return null;
}
return this.lang;
}
/*
* The trigger() function will assume that the @objChain is a complete
* chain with a method (function) at the end. It will call this function.
* So for example, when trigger() is called like so:
*
* state.trigger('videoPlayer.pause', {'param1': 10});
*
* Then trigger() will execute:
*
* state.videoPlayer.pause({'param1': 10});
*/
function trigger(objChain) {
let extraParameters = Array.prototype.slice.call(arguments, 1),
i, tmpObj, chain;
// Remember that 'this' is the 'state' object.
tmpObj = this;
chain = objChain.split('.');
// At the end of the loop the variable 'tmpObj' will either be the
// correct object/function to trigger/invoke. If the 'chain' chain of
// object is incorrect (one of the link is non-existent), then the loop
// will immediately exit.
while (chain.length) {
i = chain.shift();
if (tmpObj.hasOwnProperty(i)) {
tmpObj = tmpObj[i];
} else {
// An incorrect object chain was specified.
return false;
}
}
tmpObj.apply(this, extraParameters);
return true;
}

View File

@@ -0,0 +1,132 @@
/*
* 025_focus_grabber.js
*
* Purpose: Provide a way to focus on autohidden Video controls.
*
*
* Because in HTML player mode we have a feature of autohiding controls on
* mouse inactivity, sometimes focus is lost from the currently selected
* control. What's more, when all controls are autohidden, we can't get to any
* of them because by default browser does not place hidden elements on the
* focus chain.
*
* To get around this minor annoyance, this module will manage 2 placeholder
* elements that will be invisible to the user's eye, but visible to the
* browser. This will allow for a sneaky stealing of focus and placing it where
* we need (on hidden controls).
*
* This code has been moved to a separate module because it provides a concrete
* block of functionality that can be turned on (off).
*/
/*
* "If you want to climb a mountain, begin at the top."
*
* ~ Zen saying
*/
// FocusGrabber module.
let FocusGrabber = function(state) {
let dfd = $.Deferred();
state.focusGrabber = {};
_makeFunctionsPublic(state);
_renderElements(state);
_bindHandlers(state);
dfd.resolve();
return dfd.promise();
};
// Private functions.
function _makeFunctionsPublic(state) {
let methodsDict = {
disableFocusGrabber: disableFocusGrabber,
enableFocusGrabber: enableFocusGrabber,
onFocus: onFocus
};
state.bindTo(methodsDict, state.focusGrabber, state);
}
function _renderElements(state) {
state.focusGrabber.elFirst = state.el.find('.focus_grabber.first');
state.focusGrabber.elLast = state.el.find('.focus_grabber.last');
// From the start, the Focus Grabber must be disabled so that
// tabbing (switching focus) does not land the user on one of the
// placeholder elements (elFirst, elLast).
state.focusGrabber.disableFocusGrabber();
}
function _bindHandlers(state) {
state.focusGrabber.elFirst.on('focus', state.focusGrabber.onFocus);
state.focusGrabber.elLast.on('focus', state.focusGrabber.onFocus);
// When the video container element receives programmatic focus, then
// on un-focus ('blur' event) we should trigger a 'mousemove' event so
// as to reveal autohidden controls.
state.el.on('blur', function() {
state.el.trigger('mousemove');
});
}
// Public functions.
function enableFocusGrabber() {
let tabIndex;
// When the Focus Grabber is being enabled, there are two different
// scenarios:
//
// 1.) Currently focused element was inside the video player.
// 2.) Currently focused element was somewhere else on the page.
//
// In the first case we must make sure that the video player doesn't
// loose focus, even though the controls are autohidden.
if ($(document.activeElement).parents().hasClass('video')) {
tabIndex = -1;
} else {
tabIndex = 0;
}
this.focusGrabber.elFirst.attr('tabindex', tabIndex);
this.focusGrabber.elLast.attr('tabindex', tabIndex);
// Don't loose focus. We are inside video player on some control, but
// because we can't remain focused on a hidden element, we will shift
// focus to the main video element.
//
// Once the main element will receive the un-focus ('blur') event, a
// 'mousemove' event will be triggered, and the video controls will
// receive focus once again.
if (tabIndex === -1) {
this.el.focus();
this.focusGrabber.elFirst.attr('tabindex', 0);
this.focusGrabber.elLast.attr('tabindex', 0);
}
}
function disableFocusGrabber() {
// Only programmatic focusing on these elements will be available.
// We don't want the user to focus on them (for example with the 'Tab'
// key).
this.focusGrabber.elFirst.attr('tabindex', -1);
this.focusGrabber.elLast.attr('tabindex', -1);
}
function onFocus(event, params) {
// Once the Focus Grabber placeholder elements will gain focus, we will
// trigger 'mousemove' event so that the autohidden controls will
// become visible.
this.el.trigger('mousemove');
this.focusGrabber.disableFocusGrabber();
}
export default FocusGrabber;

View File

@@ -0,0 +1,145 @@
/* eslint-disable no-console, no-param-reassign */
/**
* HTML5 video player module to support HLS video playback.
*
*/
'use strict';
import _ from 'underscore';
import HTML5Video from './02_html5_video.js';
import HLS from 'hls';
let HLSVideo = {};
HLSVideo.Player = (function() {
/**
* Initialize HLS video player.
*
* @param {jQuery} el Reference to video player container element
* @param {Object} config Contains common config for video player
*/
function Player(el, config) {
let self = this;
this.config = config;
// do common initialization independent of player type
this.init(el, config);
_.bindAll(this, 'playVideo', 'pauseVideo', 'onReady');
// If we have only HLS sources and browser doesn't support HLS then show error message.
if (config.HLSOnlySources && !config.canPlayHLS) {
this.showErrorMessage(null, '.video-hls-error');
return;
}
this.config.state.el.on('initialize', _.once(function() {
console.log('[HLS Video]: HLS Player initialized');
self.showPlayButton();
}));
// Safari has native support to play HLS videos
if (config.browserIsSafari) {
this.videoEl.attr('src', config.videoSources[0]);
} else {
// load auto start if auto_advance is enabled
if (config.state.auto_advance) {
this.hls = new HLS({autoStartLoad: true});
} else {
this.hls = new HLS({autoStartLoad: false});
}
this.hls.loadSource(config.videoSources[0]);
this.hls.attachMedia(this.video);
this.hls.on(HLS.Events.ERROR, this.onError.bind(this));
this.hls.on(HLS.Events.MANIFEST_PARSED, function(event, data) {
console.log(
'[HLS Video]: MANIFEST_PARSED, qualityLevelsInfo: ',
data.levels.map(function(level) {
return {
bitrate: level.bitrate,
resolution: level.width + 'x' + level.height
};
})
);
self.config.onReadyHLS();
});
this.hls.on(HLS.Events.LEVEL_SWITCHED, function(event, data) {
let level = self.hls.levels[data.level];
console.log(
'[HLS Video]: LEVEL_SWITCHED, qualityLevelInfo: ',
{
bitrate: level.bitrate,
resolution: level.width + 'x' + level.height
}
);
});
}
}
Player.prototype = Object.create(HTML5Video.Player.prototype);
Player.prototype.constructor = Player;
Player.prototype.playVideo = function() {
HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['show']);
if (!this.config.browserIsSafari) {
this.hls.startLoad();
}
HTML5Video.Player.prototype.playVideo.apply(this);
};
Player.prototype.pauseVideo = function() {
HTML5Video.Player.prototype.pauseVideo.apply(this);
HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['hide']);
};
Player.prototype.onPlaying = function() {
HTML5Video.Player.prototype.onPlaying.apply(this);
HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['hide']);
};
Player.prototype.onReady = function() {
this.config.events.onReady(null);
};
/**
* Handler for HLS video errors. This only takes care of fatal erros, non-fatal errors
* are automatically handled by hls.js
*
* @param {String} event `hlsError`
* @param {Object} data Contains the information regarding error occurred.
*/
Player.prototype.onError = function(event, data) {
if (data.fatal) {
switch (data.type) {
case HLS.ErrorTypes.NETWORK_ERROR:
console.error(
'[HLS Video]: Fatal network error encountered, try to recover. Details: %s',
data.details
);
this.hls.startLoad();
break;
case HLS.ErrorTypes.MEDIA_ERROR:
console.error(
'[HLS Video]: Fatal media error encountered, try to recover. Details: %s',
data.details
);
this.hls.recoverMediaError();
break;
default:
console.error(
'[HLS Video]: Unrecoverable error encountered. Details: %s',
data.details
);
break;
}
}
};
return Player;
}());
export default HLSVideo;

View File

@@ -0,0 +1,380 @@
/* eslint-disable no-console, no-param-reassign */
/**
* @file HTML5 video player module. Provides methods to control the in-browser
* HTML5 video player.
*
* The goal was to write this module so that it closely resembles the YouTube
* API. The main reason for this is because initially the edX video player
* supported only YouTube videos. When HTML5 support was added, for greater
* compatibility, and to reduce the amount of code that needed to be modified,
* it was decided to write a similar API as the one provided by YouTube.
*
* @module HTML5Video
*/
import _ from 'underscore';
let HTML5Video = {};
HTML5Video.Player = (function() {
/*
* Constructor function for HTML5 Video player.
*
* @param {String|Object} el A DOM element where the HTML5 player will
* be inserted (as returned by jQuery(selector) function), or a
* selector string which will be used to select an element. This is a
* required parameter.
*
* @param config - An object whose properties will be used as
* configuration options for the HTML5 video player. This is an
* optional parameter. In the case if this parameter is missing, or
* some of the config object's properties are missing, defaults will be
* used. The available options (and their defaults) are as
* follows:
*
* config = {
*
* videoSources: [], // An array with properties being video
* // sources. The property name is the
* // video format of the source. Supported
* // video formats are: 'mp4', 'webm', and
* // 'ogg'.
* poster: Video poster URL
*
* browserIsSafari: Flag to tell if current browser is Safari
*
* events: { // Object's properties identify the
* // events that the API fires, and the
* // functions (event listeners) that the
* // API will call when those events occur.
* // If value is null, or property is not
* // specified, then no callback will be
* // called for that event.
*
* onReady: null,
* onStateChange: null
* }
* }
*/
function Player(el, config) {
let errorMessage, lastSource, sourceList;
// Create HTML markup for individual sources of the HTML5 <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) {
let 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) {
let 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) {
let 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() {
let 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
};
export default HTML5Video;

View File

@@ -0,0 +1,65 @@
'use strict';
import _ from 'underscore';
/**
* Video Download Transcript control module.
* @exports video/035_video_accessible_menu.js
* @constructor
* @param {jquery Element} element
* @param {Object} options
*/
let 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) {
let that = this;
let fileType;
let data;
let 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;
}
});
}
};
export default VideoTranscriptDownloadHandler;

View File

@@ -0,0 +1,85 @@
// eslint-disable-next-line lines-around-directive
'use strict';
import _ from 'underscore';
/**
* Video Social Sharing control module.
* @exports video/036_video_social_sharing.js
* @constructor
* @param {jquery Element} element
* @param {Object} options
*/
let VideoSocialSharingHandler = function(element, options) {
if (!(this instanceof VideoSocialSharingHandler)) {
return new VideoSocialSharingHandler(element, options);
}
_.bindAll(this, 'clickHandler');
_.bindAll(this, 'copyHandler');
_.bindAll(this, 'hideHandler');
_.bindAll(this, 'showHandler');
this.container = element;
if (this.container.find('.wrapper-downloads .wrapper-social-share')) {
this.initialize();
}
return false;
};
VideoSocialSharingHandler.prototype = {
// Initializes the module.
initialize: function() {
this.el = this.container.find('.wrapper-social-share');
this.baseVideoUrl = this.el.data('url');
this.course_id = this.container.data('courseId');
this.block_id = this.container.data('blockId');
this.el.on('click', '.social-share-link', this.clickHandler);
this.closeBtn = this.el.find('.close-btn');
this.toggleBtn = this.el.find('.social-toggle-btn');
this.copyBtn = this.el.find('.public-video-copy-btn');
this.shareContainer = this.el.find('.container-social-share');
this.closeBtn.on('click', this.hideHandler);
this.toggleBtn.on('click', this.showHandler);
this.copyBtn.on('click', this.copyHandler);
},
// Fire an analytics event on share button click.
clickHandler: function(event) {
let source = $(event.currentTarget).data('source');
this.sendAnalyticsEvent(source);
},
hideHandler: function(event) {
this.shareContainer.hide();
this.toggleBtn.show();
},
showHandler: function(event) {
this.shareContainer.show();
this.toggleBtn.hide();
},
copyHandler: function(event) {
navigator.clipboard.writeText(this.copyBtn.data('url'));
},
// Send an analytics event for share button tracking.
sendAnalyticsEvent: function(source) {
window.analytics.track(
'edx.social.video.share_button.clicked',
{
source,
video_block_id: this.container.data('blockId'),
course_id: this.container.data('courseId'),
}
);
}
};
export default VideoSocialSharingHandler;

View File

@@ -0,0 +1,240 @@
// VideoTranscriptFeedbackHandler module.
'use strict';
import _ from 'underscore';
/**
* @desc VideoTranscriptFeedback 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.
*
*/
let VideoTranscriptFeedback = function(state) {
if (!(this instanceof VideoTranscriptFeedback)) {
return new VideoTranscriptFeedback(state);
}
_.bindAll(this, 'destroy', 'getFeedbackForCurrentTranscript', 'markAsPositiveFeedback', 'markAsNegativeFeedback', 'markAsEmptyFeedback',
'selectThumbsUp', 'selectThumbsDown', 'unselectThumbsUp', 'unselectThumbsDown', 'thumbsUpClickHandler', 'thumbsDownClickHandler',
'sendFeedbackForCurrentTranscript', 'onHideLanguageMenu', 'getCurrentLanguage', 'loadAndSetVisibility', 'showWidget', 'hideWidget'
);
this.state = state;
this.state.videoTranscriptFeedback = this;
this.currentTranscriptLanguage = this.state.lang;
this.transcriptLanguages = this.state.config.transcriptLanguages;
if (this.state.el.find('.wrapper-transcript-feedback').length) {
this.initialize();
}
return false;
};
VideoTranscriptFeedback.prototype = {
destroy: function () {
this.state.el.off(this.events);
},
initialize: function () {
this.el = this.state.el.find('.wrapper-transcript-feedback');
this.videoId = this.el.data('video-id');
this.userId = this.el.data('user-id');
this.aiTranslationsUrl = this.state.config.aiTranslationsUrl;
this.thumbsUpButton = this.el.find('.thumbs-up-btn');
this.thumbsDownButton = this.el.find('.thumbs-down-btn');
this.thumbsUpButton.on('click', this.thumbsUpClickHandler);
this.thumbsDownButton.on('click', this.thumbsDownClickHandler);
this.events = {
'language_menu:hide': this.onHideLanguageMenu,
destroy: this.destroy
};
this.loadAndSetVisibility();
this.bindHandlers();
},
bindHandlers: function () {
this.state.el.on(this.events);
},
getFeedbackForCurrentTranscript: function () {
let self = this;
let url = self.aiTranslationsUrl + '/transcript-feedback' + '?transcript_language=' + self.currentTranscriptLanguage + '&video_id=' + self.videoId + '&user_id=' + self.userId;
$.ajax({
url: url,
type: 'GET',
success: function (data) {
if (data && data.value === true) {
self.markAsPositiveFeedback();
self.currentFeedback = true;
} else {
if (data && data.value === false) {
self.markAsNegativeFeedback();
self.currentFeedback = false;
} else {
self.markAsEmptyFeedback();
self.currentFeedback = null;
}
}
},
error: function (error) {
self.markAsEmptyFeedback();
self.currentFeedback = null;
}
});
},
markAsPositiveFeedback: function () {
this.selectThumbsUp();
this.unselectThumbsDown();
},
markAsNegativeFeedback: function () {
this.selectThumbsDown();
this.unselectThumbsUp();
},
markAsEmptyFeedback: function () {
this.unselectThumbsUp();
this.unselectThumbsDown();
},
selectThumbsUp: function () {
let thumbsUpIcon = this.thumbsUpButton.find('.thumbs-up-icon');
if (thumbsUpIcon[0].classList.contains('fa-thumbs-o-up')) {
thumbsUpIcon[0].classList.remove("fa-thumbs-o-up");
thumbsUpIcon[0].classList.add("fa-thumbs-up");
}
},
selectThumbsDown: function () {
let thumbsDownIcon = this.thumbsDownButton.find('.thumbs-down-icon');
if (thumbsDownIcon[0].classList.contains('fa-thumbs-o-down')) {
thumbsDownIcon[0].classList.remove("fa-thumbs-o-down");
thumbsDownIcon[0].classList.add("fa-thumbs-down");
}
},
unselectThumbsUp: function () {
let thumbsUpIcon = this.thumbsUpButton.find('.thumbs-up-icon');
if (thumbsUpIcon[0].classList.contains('fa-thumbs-up')) {
thumbsUpIcon[0].classList.remove("fa-thumbs-up");
thumbsUpIcon[0].classList.add("fa-thumbs-o-up");
}
},
unselectThumbsDown: function () {
let thumbsDownIcon = this.thumbsDownButton.find('.thumbs-down-icon');
if (thumbsDownIcon[0].classList.contains('fa-thumbs-down')) {
thumbsDownIcon[0].classList.remove("fa-thumbs-down");
thumbsDownIcon[0].classList.add("fa-thumbs-o-down");
}
},
thumbsUpClickHandler: function () {
if (this.currentFeedback) {
this.sendFeedbackForCurrentTranscript(null);
} else {
this.sendFeedbackForCurrentTranscript(true);
}
},
thumbsDownClickHandler: function () {
if (this.currentFeedback === false) {
this.sendFeedbackForCurrentTranscript(null);
} else {
this.sendFeedbackForCurrentTranscript(false);
}
},
sendFeedbackForCurrentTranscript: function (feedbackValue) {
let self = this;
let url = self.aiTranslationsUrl + '/transcript-feedback/';
$.ajax({
url: url,
type: 'POST',
dataType: 'json',
data: {
transcript_language: self.currentTranscriptLanguage,
video_id: self.videoId,
user_id: self.userId,
value: feedbackValue,
},
success: function (data) {
if (data && data.value === true) {
self.markAsPositiveFeedback();
self.currentFeedback = true;
} else {
if (data && data.value === false) {
self.markAsNegativeFeedback();
self.currentFeedback = false;
} else {
self.markAsEmptyFeedback();
self.currentFeedback = null;
}
}
},
error: function () {
self.markAsEmptyFeedback();
self.currentFeedback = null;
}
});
},
onHideLanguageMenu: function () {
let newLanguageSelected = this.getCurrentLanguage();
if (this.currentTranscriptLanguage !== newLanguageSelected) {
this.currentTranscriptLanguage = this.getCurrentLanguage();
this.loadAndSetVisibility();
}
},
getCurrentLanguage: function () {
let language = this.state.lang;
return language;
},
loadAndSetVisibility: function () {
let self = this;
let url = self.aiTranslationsUrl + '/video-transcript' + '?transcript_language=' + self.currentTranscriptLanguage + '&video_id=' + self.videoId;
$.ajax({
url: url,
type: 'GET',
async: false,
success: function (data) {
if (data && data.status === 'Completed') {
self.showWidget();
self.getFeedbackForCurrentTranscript();
} else {
self.hideWidget();
}
},
error: function (error) {
self.hideWidget();
}
});
},
showWidget: function () {
this.el.show();
},
hideWidget: function () {
this.el.hide();
}
};
export default VideoTranscriptFeedback;

View File

@@ -0,0 +1,911 @@
/* eslint-disable no-console, no-param-reassign */
import HTML5Video from './02_html5_video.js';
import HTML5HLSVideo from './02_html5_hls_video.js';
import Resizer from './00_resizer.js';
import HLS from 'hls';
import _ from 'underscore';
import * as Time from './utils/time.js';
let dfd = $.Deferred();
let VideoPlayer = function(state) {
state.videoPlayer = {};
_makeFunctionsPublic(state);
_initialize(state);
// No callbacks to DOM events (click, mousemove, etc.).
return dfd.promise();
};
let 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;
export default 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) {
let 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() {
let player = this.videoPlayer.player.videoEl;
let videoWidth = player[0].videoWidth || player.width();
let 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) {
let youTubeId;
let player;
let userAgent;
let commonPlayerConfig;
let 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);
}
// eslint-disable-next-line no-multi-assign
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() {
// eslint-disable-next-line no-shadow, no-multi-assign
let player = state.videoEl = state.el.find('iframe');
let videoWidth = player.attr('width') || player.width();
let 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) {
// eslint-disable-next-line no-shadow
let update = function(state) {
// eslint-disable-next-line no-shadow
let duration = state.videoPlayer.duration();
let 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() {
let 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) {
let 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) {
let 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) {
// eslint-disable-next-line no-shadow
let 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();
}
let 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() {
let 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() {
let quality;
quality = this.videoPlayer.player.getPlaybackQuality();
this.trigger('videoQualityControl.onQualityChange', quality);
this.el.trigger('qualitychange', arguments);
}
function onReady() {
let _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) {
let 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/openedx/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) {
let 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);
}
// eslint-disable-next-line no-shadow
let 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(' '));
// eslint-disable-next-line default-case
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]);
}
// eslint-disable-next-line no-shadow
function figureOutStartEndTime(duration) {
let 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);
}
}
// eslint-disable-next-line no-shadow
function figureOutStartingTime(duration) {
let 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) {
let 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() {
let playerState = this.videoPlayer.player.getPlayerState(),
ENDED = this.videoPlayer.PlayerState.ENDED;
return playerState === ENDED;
}
function isPlaying() {
let playerState = this.videoPlayer.player.getPlayerState();
return playerState === this.videoPlayer.PlayerState.PLAYING;
}
function isBuffering() {
let playerState = this.videoPlayer.player.getPlayerState();
return playerState === this.videoPlayer.PlayerState.BUFFERING;
}
function isCued() {
let playerState = this.videoPlayer.player.getPlayerState();
return playerState === this.videoPlayer.PlayerState.CUED;
}
function isUnstarted() {
let 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() {
let 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);
}

View File

@@ -0,0 +1,164 @@
'use strict';
import * as Time from './utils/time.js';
// VideoControl module.
let VideoControl = function(state) {
let 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) {
let 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() {
let _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) {
let endTime = (this.config.endTime !== null) ? this.config.endTime : params.duration,
startTime, currentTime;
// in case endTime is accidentally specified as being greater than the video
endTime = Math.min(endTime, params.duration);
startTime = this.config.startTime > 0 ? this.config.startTime : 0;
// if it's a subsection of video, use the clip duration as endTime
if (startTime && this.config.endTime) {
endTime = this.config.endTime - startTime;
}
currentTime = startTime ? params.time - startTime : params.time;
this.videoControl.vidTimeEl.text(Time.format(currentTime) + ' / ' + Time.format(endTime));
}
export default VideoControl;

View File

@@ -0,0 +1,309 @@
import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
let 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
let 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() {
let controls = this.el.find('.video-controls');
let slider = this.videoFullScreen.sliderEl;
this.videoFullScreen.height = getControlsHeight(controls, slider);
return this.videoFullScreen.height;
}
function notifyParent(fullscreenOpen) {
if (window !== window.parent) {
// This is used by the Learning MFE to know about changing fullscreen mode.
// The MFE is then able to respond appropriately and scroll window to the previous position.
window.parent.postMessage({
type: 'plugin.videoFullScreen',
payload: {
open: fullscreenOpen
}
}, document.referrer
);
}
}
/**
* Event handler to toggle fullscreen mode.
* @param {jquery Event} event
*/
function toggleHandler(event) {
event.preventDefault();
this.videoCommands.execute('toggleFullScreen');
}
function handleExit() {
let fullScreenClassNameEl = this.el.add(document.documentElement);
let closedCaptionsEl = this.el.find('.closed-captions');
if (this.isFullScreen === false) {
return;
}
// eslint-disable-next-line no-multi-assign
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]);
this.videoFullScreen.notifyParent(false);
}
function handleEnter() {
let fullScreenClassNameEl = this.el.add(document.documentElement);
let closedCaptionsEl = this.el.find('.closed-captions');
if (this.isFullScreen === true) {
return;
}
this.videoFullScreen.notifyParent(true);
// eslint-disable-next-line no-multi-assign
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) {
let methodsDict = {
destroy: destroy,
enter: enter,
exit: exit,
exitHandler: exitHandler,
handleExit: handleExit,
handleEnter: handleEnter,
handleFullscreenChange: handleFullscreenChange,
toggle: toggle,
toggleHandler: toggleHandler,
updateControlsHeight: updateControlsHeight,
notifyParent: notifyParent
};
state.bindTo(methodsDict, state.videoFullScreen, state);
}
// VideoControl() function - what this module "exports".
export default function(state) {
let dfd = $.Deferred();
// eslint-disable-next-line no-param-reassign
state.videoFullScreen = {};
makeFunctionsPublic(state);
renderElements(state);
bindHandlers(state);
dfd.resolve();
return dfd.promise();
}

View File

@@ -0,0 +1,176 @@
'use strict';
import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
import _ from 'underscore';
let 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>&nbsp;',
'<span class="sr control-text">',
'{off}',
'</span>',
'</button>'
].join('')),
{
highDefinition: gettext('High Definition'),
off: gettext('off')
}
);
// VideoQualityControl() function - what this module "exports".
let VideoQualityControl = function(state) {
let 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) {
let 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) {
// eslint-disable-next-line no-multi-assign
let 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() {
let 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) {
let 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) {
let value = this.videoQualityControl.quality;
let isHD = _.contains(this.config.availableHDQualities, value);
let newQuality = isHD ? 'large' : 'highres';
event.preventDefault();
this.trigger('videoPlayer.handlePlaybackQualityChange', newQuality);
}
export default VideoQualityControl;

View File

@@ -0,0 +1,360 @@
/*
"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.
let template = [
'<div class="slider" role="application" title="',
gettext('Video position. Press space to toggle playback'),
'"></div>'
].join('');
// VideoProgressSlider() function - what this module "exports".
export default function(state) {
let 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) {
let 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() {
let 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) {
let 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) {
let step = 100 / duration;
let left = startTime * step;
let width = endTime * step - left;
return {
left: left + '%',
width: width + '%'
};
}
function onSlide(event, ui) {
let time = ui.value;
let 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) {
let _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) {
let 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
let 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) {
let seconds = Math.floor(time);
let minutes = Math.floor(seconds / 60);
let hours = Math.floor(minutes / 60);
let i18n = function(value, word) {
let msg;
// eslint-disable-next-line default-case
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 %= 60;
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');
}
}

View File

@@ -0,0 +1,554 @@
'use strict';
import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
/**
* 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}
*/
let VideoVolumeControl = function (state, i18n) {
if (!(this instanceof VideoVolumeControl)) {
return new VideoVolumeControl(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();
};
VideoVolumeControl.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();
// eslint-disable-next-line no-multi-assign
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 () {
let 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 () {
let 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 () {
let 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 () {
let 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) {
let 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) {
let 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;
}
let KEY = $.ui.keyCode,
keyCode = event.keyCode;
// eslint-disable-next-line default-case
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;
}
let KEY = $.ui.keyCode,
keyCode = event.keyCode;
// eslint-disable-next-line default-case
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.
*/
let 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.
*/
let 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 () {
let 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: '/'
});
}
};
export default VideoVolumeControl;

View File

@@ -0,0 +1,134 @@
'use strict';
import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
import _ from 'underscore';
/**
* 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}
*/
let 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() {
let 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) {
let 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.
let isFirstComponent = this.state.el.parents('.vert-0').length === 1;
if (this.state.auto_advance && isFirstComponent) {
this.state.videoCommands.execute('play');
}
},
autoAdvance: function() {
// We are posting a message to the MFE and then let the eventlistener
// in the MFE handle the action taken.
if (this.state.auto_advance) {
if (window !== window.parent) {
window.parent.postMessage({
type: 'plugin.autoAdvance',
payload: {}
}, document.referrer
);
}
}
}
};
export default AutoAdvanceControl;

View File

@@ -0,0 +1,417 @@
'use strict';
import Iterator from './00_iterator.js';
import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
/**
* 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}
*/
function VideoSpeedControl(state) {
if (!(this instanceof VideoSpeedControl)) {
return new VideoSpeedControl(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();
};
VideoSpeedControl.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 () {
let 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) {
let 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) {
let isHtml5 = state.videoType === 'html5',
isTouch = state.isTouch,
video = document.createElement('video');
// eslint-disable-next-line no-extra-boolean-cast
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) {
let 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 () {
let speedOptions = this.speedsContainer.find('li');
$(speedOptions).each(function (index, el) {
$(el).removeClass('is-active')
.find('.speed-option')
.attr('aria-pressed', 'false');
});
},
setActiveSpeed: function (speed) {
let 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) {
let 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) {
let KEY = $.ui.keyCode,
keyCode = event.keyCode;
// eslint-disable-next-line default-case
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;
}
let KEY = $.ui.keyCode,
self = this,
parent = $(event.currentTarget).parent(),
index = parent.index(),
speed = parent.data('speed');
// eslint-disable-next-line default-case
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;
}
};
export default VideoSpeedControl;

View File

@@ -0,0 +1,698 @@
import _ from 'underscore';
import Component from './00_component.js';
let 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) {
let 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 () {
let item = this.next;
while (item.isHidden() && this.id !== item.id) {
item = item.next;
}
return item;
},
getPrev: function () {
let item = this.prev;
while (item.isHidden() && this.id !== item.id) {
item = item.prev;
}
return item;
},
createElement: function () {
return null;
},
getRoot: function () {
let 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 () {
let 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(' ');
}
});
let 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 () {
let 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();
let item = $(event.target).data('menu');
// eslint-disable-next-line default-case
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 () {
}
});
let 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) {
let $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) {
let 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 () {
let $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) {
let 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) {
let KEY = $.ui.keyCode,
keyCode = event.keyCode;
// eslint-disable-next-line default-case
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;
}
});
let 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) {
let $elem = $(this.getElement());
$(container).append($elem);
this.delegateEvents();
return this;
},
delegateEvents: function () {
let 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;
}
});
let Submenu = AbstractMenu.extend({
initialize: function (options, contextmenuElement) {
this.contextmenuElement = contextmenuElement;
AbstractMenu.prototype.initialize.apply(this, arguments);
},
createElement: function () {
let $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) {
let $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) {
let KEY = $.ui.keyCode,
keyCode = event.keyCode;
// eslint-disable-next-line default-case
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();
}
});
let MenuItem = AbstractItem.extend({
createElement: function () {
let 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();
// eslint-disable-next-line default-case
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) {
let KEY = $.ui.keyCode,
keyCode = event.keyCode;
// eslint-disable-next-line default-case
switch (keyCode) {
case KEY.RIGHT:
event.stopPropagation();
break;
case KEY.ENTER:
case KEY.SPACE:
this.select();
event.stopPropagation();
break;
}
return false;
}
});
let VideoContextMenu = function(state, i18n) {
let speedCallback = function(event, menuitem, options) {
let speed = parseFloat(options.label);
state.videoCommands.execute('speed', speed);
}
let 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) {
let 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) {
// eslint-disable-next-line no-shadow
let item = menuitem.getChildren().filter(function (item) {
return item.options.speed === speed;
})[0];
if (item) {
item.select();
}
}
});
}
}
]
};
// eslint-disable-next-line no-shadow
$.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 () {
let contextmenu = $(this).find('video').data('contextmenu');
if (contextmenu) {
contextmenu.destroy();
}
});
}
return $.Deferred().resolve().promise();
}
export default VideoContextMenu

View File

@@ -0,0 +1,108 @@
'use strict';
/**
* 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}
*/
let 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() {
let events = ['ended', 'error'].join(' ');
this.element.on(events, this.showMainVideoHandler);
this.element.on('timeupdate', this.skipByDuration);
},
saveState: function() {
let 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() {
let 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;
}
};
export default VideoBumper;

View File

@@ -0,0 +1,201 @@
'use strict';
/**
* Completion handler
* @exports video/09_completion.js
* @constructor
* @param {Object} state The object containing the state of the video
* @return {jquery Promise}
*/
let 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() {
let 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) {
let 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() {
let 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) {
let self = this;
let 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;
}
};
export default VideoCompletionHandler;

View File

@@ -0,0 +1,112 @@
'use strict';
import _ from 'underscore';
/**
* 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}
*/
let 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) {
let info = {currentTime: this.getCurrentTime()};
let 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() {
let player = this.state.videoPlayer;
return player ? player.currentTime : 0;
},
getDuration: function() {
let player = this.state.videoPlayer;
return player ? player.duration() : 0;
},
log: function(eventName, data) {
let 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);
}
};
export default EventsBumperPlugin;

View File

@@ -0,0 +1,177 @@
'use strict';
import _ from 'underscore';
/**
* 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}
*/
let 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) {
let info = {currentTime: this.getCurrentTime()};
let 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() {
let player = this.state.videoPlayer;
let startTime = this.state.config.startTime;
let currentTime = player ? player.currentTime : 0;
// if video didn't start from 0(it's a subsection of video), subtract the additional time at start
if (startTime) {
currentTime = currentTime ? currentTime - startTime : 0;
}
return currentTime;
},
getCurrentLanguage: function() {
let language = this.state.lang;
return language;
},
log: function(eventName, data) {
// use startTime and endTime to calculate the duration to handle the case where only a subsection of video is used
let endTime = this.state.config.endTime || this.state.duration;
let startTime = this.state.config.startTime || 0;
let 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: endTime - startTime
}, data, this.options.data);
Logger.log(eventName, logInfo);
}
};
export default EventsPlugin;

View File

@@ -0,0 +1,96 @@
'use strict';
import _ from 'underscore';
/**
* 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}
*/
let 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');
}
};
export default PlayPauseControl;

View File

@@ -0,0 +1,84 @@
'use strict';
/**
* 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}
*/
let 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});
}
};
export default PlayPlaceholder;

View File

@@ -0,0 +1,86 @@
'use strict';
/**
* 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}
*/
let 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');
}
};
export default PlaySkipControl;

View File

@@ -0,0 +1,62 @@
'use strict';
import _ from 'underscore';
let 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();
}
};
export default VideoPoster;

View File

@@ -0,0 +1,131 @@
'use strict';
import _ from 'underscore';
import { convert, format, formatFull } from './utils/time.js';
/**
* 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}
*/
let 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) {
let 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 = formatFull(data.saved_video_position);
}
$.ajax({
url: this.state.config.saveStateUrl,
type: 'POST',
async: !!async,
dataType: 'json',
data: data
});
}
}
};
export default SaveStatePlugin;

View File

@@ -0,0 +1,72 @@
'use strict';
import _ from 'underscore';
/**
* 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}
*/
let 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);
}
};
export default SkipControl;

View File

@@ -0,0 +1,1459 @@
// VideoCaption module.
import Sjson from './00_sjson.js';
import AsyncProcess from './00_async_process.js';
import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
import Draggabilly from 'draggabilly';
import { convert } from './utils/time.js';
import _ from 'underscore';
'use strict';
/**
* @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}
*/
let 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',
'updateGoogleDisclaimer', 'toggleGoogleDisclaimer', 'updateProblematicCaptionsContent'
);
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() {
let languages = this.state.config.transcriptLanguages;
let 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
}
);
let 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() {
let 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) {
let KEY = $.ui.keyCode,
keyCode = event.keyCode;
switch (keyCode) {
case KEY.SPACE:
case KEY.ENTER:
event.preventDefault();
this.toggleClosedCaptions(event);
// no default
}
},
handleTranscriptToggle: function(event) {
let KEY = $.ui.keyCode,
keyCode = event.keyCode;
switch (keyCode) {
case KEY.SPACE:
case KEY.ENTER:
event.preventDefault();
this.toggleTranscript(event);
// no default
}
},
handleKeypressLink: function(event) {
let 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) {
let 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) {
let button = this.languageChooserEl,
menu = button.parent().find('.menu');
event.preventDefault();
button
.addClass('is-opened');
menu
.find('.control-lang').last()
.focus();
},
closeLanguageMenu: function(event) {
let 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
let config = this.state.config;
let startTime = config.startTime * 1000;
let 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.
let times = this.getStartEndTimes();
// eslint-disable-next-line prefer-spread
let results = this.sjson.filter.apply(this.sjson, times);
let start = results.start;
let captions = results.captions;
return {
start: start,
captions: captions
};
},
/**
* @desc Sets whether or not the Google disclaimer should be shown based on captions
* being AI generated, and shows/hides based on the above and if ClosedCaptions are being shown.
*
* @param {array} captions List of captions for the video.
*
* @returns {boolean}
*/
updateGoogleDisclaimer: function(captions) {
const aIGeneratedSpanText = '<span id="captions-ai-generated"',
aiProviderRegexp = /data\-provider=["'](?<provider>\w+)["']/;
let self = this,
state = this.state,
aiGeneratedSpan = captions.find(caption => caption.includes(aIGeneratedSpanText)),
captionsAIGenerated = !(aiGeneratedSpan === undefined),
aiCaptionProviderIsGoogle = true;
if (captionsAIGenerated) {
const providerMatch = aiProviderRegexp.exec(aiGeneratedSpan);
if (providerMatch !== null) {
aiCaptionProviderIsGoogle = providerMatch.groups['provider'] === 'gcp';
}
// If there is no provider tag, it was generated before we added those,
// so it must be Google
}
// This field is whether or not, in general, this video should show the google disclaimer
self.shouldShowGoogleDisclaimer = captionsAIGenerated && aiCaptionProviderIsGoogle;
// Should we, right now, on load, show the google disclaimer
self.toggleGoogleDisclaimer(!self.hideCaptionsOnLoad && !state.captionsHidden);
},
/**
* @desc Show or hide the google translate disclaimer based on the passed param
* and whether or not we are currently showing a google translated transcript.
* @param {boolean} [show] Show if true, hide if false - if we are showing a google
* translated transcript. If not, this will always hide.
*/
toggleGoogleDisclaimer: function(show) {
let self = this,
state = this.state;
if (show && self.shouldShowGoogleDisclaimer) {
state.el.find('.google-disclaimer').show();
} else {
state.el.find('.google-disclaimer').hide();
}
},
/**
* @desc Replaces content in a caption
*
* @param {array} captions List of captions for the video.
* @param {string} content content to be replaced
* @param {string} replacementContent the replace string
*
* @returns {array} captions List of captions for the video.
*/
updateProblematicCaptionsContent: function(captions, content = '', replacementContent = '') {
let updatedCaptions = captions.map(caption => caption.replace(content, replacementContent));
return updatedCaptions;
},
/**
* @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) {
let 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) {
let results, start, captions;
self.sjson = new Sjson(sjson);
results = self.getBoundedCaptions();
start = results.start;
captions = results.captions;
let contentToReplace = CAPTIONS_CONTENT_TO_REPLACE,
replacementContent = CAPTIONS_CONTENT_REPLACEMENT;
captions = self.updateProblematicCaptionsContent(captions, contentToReplace, replacementContent);
self.updateGoogleDisclaimer(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) {
let 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() {
let self = this,
state = this.state;
this.availableTranslationsXHR = $.ajaxWithPrefix({
url: state.config.transcriptAvailableTranslationsUrl,
notifyOnError: false,
success: function(response) {
let 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) {
let 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) {
let 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) {
let process = function(text, index) {
let $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) {
let self = this;
let 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() {
let 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()
}
);
let 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) {
let $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) {
let $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) {
let $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) {
let $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() {
let 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() {
let 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) {
let state = this.state,
params, newIndex, times;
if (this.loaded) {
if (state.isFlashMode()) {
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);
// eslint-disable-next-line prefer-spread
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')
.attr('aria-current', 'false')
.removeClass('current');
}
this.subtitlesEl
.find("span[data-index='" + newIndex + "']")
.parent()
.attr('aria-current', 'true')
.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) {
let 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();
this.toggleGoogleDisclaimer(false);
} else {
this.state.showClosedCaptions = true;
this.updateCaptioningCookie(true);
this.showClosedCaptions();
this.toggleGoogleDisclaimer(true);
}
},
showClosedCaptions: function() {
let 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() {
let 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() {
let 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() {
let captions = this.captionDisplayEl['0'];
if (typeof Draggabilly === 'function') {
// eslint-disable-next-line no-new
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) {
let transcriptControlEl = this.transcriptControlEl,
self = this,
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');
}
self.toggleGoogleDisclaimer(false);
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');
}
self.toggleGoogleDisclaimer(true);
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() {
let 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() {
let 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
});
}
};
export default VideoCaption;

View File

@@ -0,0 +1,108 @@
'use strict';
import _ from 'underscore';
/**
* 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}
*/
let 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) {
let 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() {
let commands = {};
let commandsList = [
playCommand, pauseCommand, togglePlaybackCommand,
toggleMuteCommand, toggleFullScreenCommand, setSpeedCommand,
skipCommand
];
_.each(commandsList, function(command) {
commands[command.name] = command;
}, this);
return commands;
}
};
let Command = function(name, execute) {
this.name = name;
this.execute = execute;
};
let playCommand = new Command('play', function(state) {
state.videoPlayer.play();
});
let pauseCommand = new Command('pause', function(state) {
state.videoPlayer.pause();
});
let togglePlaybackCommand = new Command('togglePlayback', function(state) {
if (state.videoPlayer.isPlaying()) {
pauseCommand.execute(state);
} else {
playCommand.execute(state);
}
});
let toggleMuteCommand = new Command('toggleMute', function(state) {
state.videoVolumeControl.toggleMute();
});
let toggleFullScreenCommand = new Command('toggleFullScreen', function(state) {
state.videoFullScreen.toggle();
});
let setSpeedCommand = new Command('speed', function(state, speed) {
state.videoSpeedControl.setSpeed(state.speedToString(speed));
});
let skipCommand = new Command('skip', function(state, doNotShowAgain) {
if (doNotShowAgain) {
state.videoBumper.skipAndDoNotShowAgain();
} else {
state.videoBumper.skip();
}
});
export default VideoCommands;

View File

@@ -0,0 +1,133 @@
'use strict';
import VideoStorage from './00_video_storage.js';
import initialize from './01_initialize.js';
import FocusGrabber from './025_focus_grabber.js';
import VideoAccessibleMenu from './035_video_accessible_menu.js';
import VideoControl from './04_video_control.js';
import VideoFullScreen from './04_video_full_screen.js';
import VideoQualityControl from './05_video_quality_control.js';
import VideoProgressSlider from './06_video_progress_slider.js';
import VideoVolumeControl from './07_video_volume_control.js';
import VideoSpeedControl from './08_video_speed_control.js';
import VideoAutoAdvanceControl from './08_video_auto_advance_control.js';
import VideoCaption from './09_video_caption.js';
import VideoPlayPlaceholder from './09_play_placeholder.js';
import VideoPlayPauseControl from './09_play_pause_control.js';
import VideoPlaySkipControl from './09_play_skip_control.js';
import VideoSkipControl from './09_skip_control.js';
import VideoBumper from './09_bumper.js';
import VideoSaveStatePlugin from './09_save_state_plugin.js';
import VideoEventsPlugin from './09_events_plugin.js';
import VideoEventsBumperPlugin from './09_events_bumper_plugin.js';
import VideoPoster from './09_poster.js';
import VideoCompletionHandler from './09_completion.js';
import VideoCommands from './10_commands.js';
import VideoContextMenu from './095_video_context_menu.js';
import VideoSocialSharing from './036_video_social_sharing.js';
import VideoTranscriptFeedback from './037_video_transcript_feedback.js';
let youtubeXhr = null;
window.Video = function (runtime, element) {
let 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, VideoTranscriptFeedback
].concat(autoAdvanceEnabled ? [VideoAutoAdvanceControl] : []),
bumperVideoModules = [VideoControl, VideoPlaySkipControl, VideoSkipControl,
VideoVolumeControl, VideoCaption, VideoCommands, VideoSaveStatePlugin, VideoTranscriptFeedback,
VideoEventsBumperPlugin, VideoCompletionHandler],
state = {
el: el,
id: id,
metadata: el.data('metadata'),
storage: storage,
options: {},
youtubeXhr: youtubeXhr,
modules: mainVideoModules
};
let getBumperState = function (metadata) {
let 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;
};
let player = function (innerState) {
return function () {
_.extend(innerState.metadata, {autoplay: true, focusFirstControl: true});
initialize(innerState, element);
};
};
let onSequenceChange;
VideoAccessibleMenu(el, {
storage: storage,
saveStateUrl: state.metadata.saveStateUrl
});
VideoSocialSharing(el);
if (bumperMetadata) {
VideoPoster(el, {
poster: el.data('poster'),
onClick: _.once(function () {
let mainVideoPlayer = player(state);
let 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;

View File

@@ -1,6 +1,6 @@
// eslint-disable-next-line no-shadow
function format(time, formatFull) {
var hours, minutes, seconds;
export function format(time, formatFull) {
let hours, minutes, seconds;
if (!_.isFinite(time) || time < 0) {
time = 0;
@@ -21,23 +21,21 @@ function format(time, formatFull) {
}
}
function formatFull(time) {
export function formatFull(time) {
// The returned value will not be user-facing. So no need for
// internationalization.
return format(time, true);
}
function convert(time, oldSpeed, newSpeed) {
export function convert(time, oldSpeed, newSpeed) {
// eslint-disable-next-line no-mixed-operators
return (time * oldSpeed / newSpeed).toFixed(3);
}
function _pad(number) {
export function _pad(number) {
if (number < 10) {
return '0' + number;
} else {
return '' + number;
}
}
export {format, formatFull, convert};

View File

@@ -27,12 +27,16 @@ import '../../common/static/common/js/spec_helpers/jasmine-waituntil.js';
import '../../common/static/common/js/spec_helpers/jasmine-extensions.js';
import '../../common/static/common/js/vendor/sinon.js';
import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
import StringUtils from 'edx-ui-toolkit/js/utils/string-utils';
// These libraries are used by the tests (and the code under test)
// but not explicitly imported
import 'jquery.ui';
// These
import './src/video/10_main.js';
import '../assets/video/public/js/10_main.js';
import './spec/helper.js';
import './spec/video_helper.js';
@@ -71,11 +75,18 @@ import './spec/video/video_transcript_feedback_spec.js';
import './spec/video/video_volume_control_spec.js';
import './spec/time_spec.js';
// overwrite the loaded method and manually start the karma after a delay
// Somehow the code initialized in jQuery's onready doesn't get called before karma auto starts
// eslint-disable-next-line no-unused-expressions
'use strict';
window._ = _;
window.edx = window.edx || {};
window.edx.HtmlUtils = HtmlUtils;
window.edx.StringUtils = StringUtils;
window.__karma__.loaded = function() {
setTimeout(function() {
window.__karma__.start();

View File

@@ -23,8 +23,8 @@ var options = {
specFiles: [],
fixtureFiles: [
{pattern: 'fixtures/*.*'},
{pattern: 'fixtures/hls/**/*.*'}
{pattern: path.join(__dirname, 'fixtures/*.*')},
{pattern: path.join(__dirname, 'fixtures/hls/**/*.*')}
],
runFiles: [

View File

@@ -255,7 +255,8 @@
}
jasmine.stubRequests();
state = new window.Video('#example');
let runtime = jasmine.createSpyObj('TestRuntime', ['handlerUrl']);
state = new window.Video(runtime, '#example');
state.resizer = (function() {
var methods = [

View File

@@ -1,6 +1,6 @@
'use strict';
import * as Time from 'time.js';
import * as Time from '../../assets/video/public/js/utils/time.js';
describe('Time', function() {
describe('format', function() {

View File

@@ -1,81 +1,77 @@
(function(require) {
require(
['video/00_async_process.js'],
function(AsyncProcess) {
var getArrayNthLength = function(n, multiplier) {
var result = [],
mul = multiplier || 1;
import AsyncProcess from '../../../assets/video/public/js/00_async_process.js';
for (var i = 0; i < n; i++) {
result[i] = i * mul;
}
let getArrayNthLength = function(n, multiplier) {
let result = [],
mul = multiplier || 1;
return result;
},
items = getArrayNthLength(1000);
for (let i = 0; i < n; i++) {
result[i] = i * mul;
}
describe('AsyncProcess', function() {
it('Array is processed successfully', function(done) {
var processedArray,
expectedArray = getArrayNthLength(1000, 2),
process = function(item) {
return 2 * item;
};
return result;
},
items = getArrayNthLength(1000);
AsyncProcess.array(items, process).done(function(result) {
processedArray = result;
});
describe('AsyncProcess', function() {
it('Array is processed successfully', function(done) {
var processedArray,
expectedArray = getArrayNthLength(1000, 2),
process = function(item) {
return 2 * item;
};
jasmine.waitUntil(function() {
return processedArray;
}).then(function() {
expect(processedArray).toEqual(expectedArray);
}).always(done);
});
it('If non-array is passed, error callback is called', function(done) {
var isError,
process = function() {};
AsyncProcess.array('string', process).fail(function() {
isError = true;
});
jasmine.waitUntil(function() {
return isError;
}).then(function() {
expect(isError).toBeTruthy();
}).always(done);
});
it('If an empty array is passed, returns initial array', function(done) {
var processedArray,
process = function() {};
AsyncProcess.array([], process).done(function(result) {
processedArray = result;
});
jasmine.waitUntil(function() {
return processedArray;
}).then(function() {
expect(processedArray).toEqual([]);
}).always(done);
});
it('If no process function passed, returns initial array', function(done) {
var processedArray;
AsyncProcess.array(items).done(function(result) {
processedArray = result;
});
jasmine.waitUntil(function() {
return processedArray;
}).then(function() {
expect(processedArray).toEqual(items);
}).always(done);
});
});
AsyncProcess.array(items, process).done(function(result) {
processedArray = result;
});
}(require));
jasmine.waitUntil(function() {
return processedArray;
}).then(function() {
expect(processedArray).toEqual(expectedArray);
}).always(done);
});
it('If non-array is passed, error callback is called', function(done) {
var isError,
process = function() {};
AsyncProcess.array('string', process).fail(function() {
isError = true;
});
jasmine.waitUntil(function() {
return isError;
}).then(function() {
expect(isError).toBeTruthy();
}).always(done);
});
it('If an empty array is passed, returns initial array', function(done) {
var processedArray,
process = function() {};
AsyncProcess.array([], process).done(function(result) {
processedArray = result;
});
jasmine.waitUntil(function() {
return processedArray;
}).then(function() {
expect(processedArray).toEqual([]);
}).always(done);
});
it('If no process function passed, returns initial array', function(done) {
var processedArray;
AsyncProcess.array(items).done(function(result) {
processedArray = result;
});
jasmine.waitUntil(function() {
return processedArray;
}).then(function() {
expect(processedArray).toEqual(items);
}).always(done);
});
});

View File

@@ -1,330 +1,324 @@
(function(require) {
'use strict';
import Initialize from '../../../assets/video/public/js/01_initialize.js';
require(
['video/01_initialize.js'],
function(Initialize) {
describe('Initialize', function() {
var state = {};
describe('Initialize', function() {
var state = {};
afterEach(function() {
state = {};
});
afterEach(function() {
state = {};
});
describe('getCurrentLanguage', function() {
var msg;
describe('getCurrentLanguage', function() {
var msg;
beforeEach(function() {
state.config = {};
state.config.transcriptLanguages = {
de: 'German',
en: 'English',
uk: 'Ukrainian'
};
});
beforeEach(function() {
state.config = {};
state.config.transcriptLanguages = {
de: 'German',
en: 'English',
uk: 'Ukrainian'
};
});
it('returns current language', function() {
var expected;
it('returns current language', function() {
var expected;
state.lang = 'de';
expected = Initialize.prototype.getCurrentLanguage.call(state);
expect(expected).toBe('de');
});
state.lang = 'de';
expected = Initialize.prototype.getCurrentLanguage.call(state);
expect(expected).toBe('de');
});
msg = 'returns `en`, if language isn\'t available for the video';
it(msg, function() {
var expected;
msg = 'returns `en`, if language isn\'t available for the video';
it(msg, function() {
var expected;
state.lang = 'zh';
expected = Initialize.prototype.getCurrentLanguage.call(state);
expect(expected).toBe('en');
});
state.lang = 'zh';
expected = Initialize.prototype.getCurrentLanguage.call(state);
expect(expected).toBe('en');
});
msg = 'returns any available language, if current and `en` '
+ 'languages aren\'t available for the video';
it(msg, function() {
var expected;
msg = 'returns any available language, if current and `en` '
+ 'languages aren\'t available for the video';
it(msg, function() {
var expected;
state.lang = 'zh';
state.config.transcriptLanguages = {
de: 'German',
uk: 'Ukrainian'
};
expected = Initialize.prototype.getCurrentLanguage.call(state);
expect(expected).toBe('uk');
});
state.lang = 'zh';
state.config.transcriptLanguages = {
de: 'German',
uk: 'Ukrainian'
};
expected = Initialize.prototype.getCurrentLanguage.call(state);
expect(expected).toBe('uk');
});
it('returns `null`, if transcript unavailable', function() {
var expected;
it('returns `null`, if transcript unavailable', function() {
var expected;
state.lang = 'zh';
state.config.transcriptLanguages = {};
expected = Initialize.prototype.getCurrentLanguage.call(state);
expect(expected).toBeNull();
});
});
state.lang = 'zh';
state.config.transcriptLanguages = {};
expected = Initialize.prototype.getCurrentLanguage.call(state);
expect(expected).toBeNull();
});
});
describe('getDuration', function() {
beforeEach(function() {
state = {
speed: '1.50',
metadata: {
testId: {
duration: 'PT6M40S'
},
videoId: {
duration: 'PT1M40S'
}
},
videos: {
'1.0': 'testId',
'1.50': 'videoId'
},
youtubeId: Initialize.prototype.youtubeId,
isFlashMode: jasmine.createSpy().and.returnValue(false)
};
});
describe('getDuration', function() {
beforeEach(function() {
state = {
speed: '1.50',
metadata: {
testId: {
duration: 'PT6M40S'
},
videoId: {
duration: 'PT1M40S'
}
},
videos: {
'1.0': 'testId',
'1.50': 'videoId'
},
youtubeId: Initialize.prototype.youtubeId,
isFlashMode: jasmine.createSpy().and.returnValue(false)
};
});
it('returns duration for the 1.0 speed if speed is not 1.0', function() {
var expected;
it('returns duration for the 1.0 speed if speed is not 1.0', function() {
var expected;
state.speed = '1.50';
expected = Initialize.prototype.getDuration.call(state);
state.speed = '1.50';
expected = Initialize.prototype.getDuration.call(state);
expect(expected).toEqual(400);
});
expect(expected).toEqual(400);
});
describe('Flash mode', function() {
it('returns duration for current video', function() {
var expected;
describe('Flash mode', function() {
it('returns duration for current video', function() {
var expected;
state.isFlashMode.and.returnValue(true);
expected = Initialize.prototype.getDuration.call(state);
state.isFlashMode.and.returnValue(true);
expected = Initialize.prototype.getDuration.call(state);
expect(expected).toEqual(100);
});
expect(expected).toEqual(100);
});
it('returns duration for the 1.0 speed as a fall-back', function() {
var expected;
it('returns duration for the 1.0 speed as a fall-back', function() {
var expected;
state.isFlashMode.and.returnValue(true);
state.speed = '2.0';
expected = Initialize.prototype.getDuration.call(state);
state.isFlashMode.and.returnValue(true);
state.speed = '2.0';
expected = Initialize.prototype.getDuration.call(state);
expect(expected).toEqual(400);
});
});
});
expect(expected).toEqual(400);
});
});
});
describe('youtubeId', function() {
beforeEach(function() {
state = {
speed: '1.50',
videos: {
'0.50': '7tqY6eQzVhE',
'1.0': 'cogebirgzzM',
'1.50': 'abcdefghijkl'
},
isFlashMode: jasmine.createSpy().and.returnValue(false)
};
});
describe('youtubeId', function() {
beforeEach(function() {
state = {
speed: '1.50',
videos: {
'0.50': '7tqY6eQzVhE',
'1.0': 'cogebirgzzM',
'1.50': 'abcdefghijkl'
},
isFlashMode: jasmine.createSpy().and.returnValue(false)
};
});
describe('with speed', function() {
it('return the video id for given speed', function() {
$.each(state.videos, function(speed, videoId) {
var expected = Initialize.prototype.youtubeId.call(
state, speed
);
describe('with speed', function() {
it('return the video id for given speed', function() {
$.each(state.videos, function(speed, videoId) {
var expected = Initialize.prototype.youtubeId.call(
state, speed
);
expect(videoId).toBe(expected);
});
});
});
describe('without speed for flash mode', function() {
it('return the video id for current speed', function() {
var expected;
state.isFlashMode.and.returnValue(true);
expected = Initialize.prototype.youtubeId.call(state);
expect(expected).toEqual('abcdefghijkl');
});
});
describe('without speed for youtube html5 mode', function() {
it('return the video id for 1.0x speed', function() {
var expected = Initialize.prototype.youtubeId.call(state);
expect(expected).toEqual('cogebirgzzM');
});
});
describe('speed is absent in the list of video speeds', function() {
it('return the video id for 1.0x speed', function() {
var expected = Initialize.prototype.youtubeId.call(state, '0.0');
expect(expected).toEqual('cogebirgzzM');
});
});
});
describe('setSpeed', function() {
describe('YT', function() {
beforeEach(function() {
state = {
speeds: ['0.25', '0.50', '1.0', '1.50', '2.0'],
storage: jasmine.createSpyObj('storage', ['setItem'])
};
});
it('check mapping', function() {
var map = {
0.75: '0.50',
1.25: '1.50'
};
$.each(map, function(key, expected) {
Initialize.prototype.setSpeed.call(state, key);
expect(state.speed).toBe(parseFloat(expected));
});
});
});
describe('HTML5', function() {
beforeEach(function() {
state = {
speeds: ['0.75', '1.0', '1.25', '1.50', '2.0'],
storage: jasmine.createSpyObj('storage', ['setItem'])
};
});
describe('when 0.75 speed is available', function() {
beforeEach(function() {
Initialize.prototype.setSpeed.call(state, '0.75');
});
it('set new speed', function() {
expect(state.speed).toEqual(0.75);
});
});
describe('when 2.0 speed is available', function() {
beforeEach(function() {
Initialize.prototype.setSpeed.call(state, '2.0');
});
it('set new speed', function() {
expect(state.speed).toEqual(2.0);
});
});
describe('when new speed is not available', function() {
beforeEach(function() {
Initialize.prototype.setSpeed.call(state, '1.75');
});
it('set speed to 1.0x', function() {
expect(state.speed).toEqual(1);
});
});
it('check mapping', function() {
var map = {
0.25: '0.75',
'0.50': '0.75'
};
$.each(map, function(key, expected) {
Initialize.prototype.setSpeed.call(state, key);
expect(state.speed).toBe(parseFloat(expected));
});
});
});
});
describe('setPlayerMode', function() {
beforeEach(function() {
state = {
currentPlayerMode: 'flash'
};
});
it('updates player mode', function() {
var setPlayerMode = Initialize.prototype.setPlayerMode;
setPlayerMode.call(state, 'html5');
expect(state.currentPlayerMode).toBe('html5');
setPlayerMode.call(state, 'flash');
expect(state.currentPlayerMode).toBe('flash');
});
it('sets default mode if passed is not supported', function() {
var setPlayerMode = Initialize.prototype.setPlayerMode;
setPlayerMode.call(state, '77html77');
expect(state.currentPlayerMode).toBe('html5');
});
});
describe('getPlayerMode', function() {
beforeEach(function() {
state = {
currentPlayerMode: 'flash'
};
});
it('returns current player mode', function() {
var getPlayerMode = Initialize.prototype.getPlayerMode,
actual = getPlayerMode.call(state);
expect(actual).toBe(state.currentPlayerMode);
});
});
describe('isFlashMode', function() {
it('returns `true` if player in `flash` mode', function() {
var testState = {
getPlayerMode: jasmine.createSpy().and.returnValue('flash')
},
isFlashMode = Initialize.prototype.isFlashMode,
actual = isFlashMode.call(testState);
expect(actual).toBeTruthy();
});
it('returns `false` if player is not in `flash` mode', function() {
var testState = {
getPlayerMode: jasmine.createSpy().and.returnValue('html5')
},
isFlashMode = Initialize.prototype.isFlashMode,
actual = isFlashMode.call(testState);
expect(actual).toBeFalsy();
});
});
describe('isHtml5Mode', function() {
it('returns `true` if player in `html5` mode', function() {
var testState = {
getPlayerMode: jasmine.createSpy().and.returnValue('html5')
},
isHtml5Mode = Initialize.prototype.isHtml5Mode,
actual = isHtml5Mode.call(testState);
expect(actual).toBeTruthy();
});
it('returns `false` if player is not in `html5` mode', function() {
var testState = {
getPlayerMode: jasmine.createSpy().and.returnValue('flash')
},
isHtml5Mode = Initialize.prototype.isHtml5Mode,
actual = isHtml5Mode.call(testState);
expect(actual).toBeFalsy();
});
expect(videoId).toBe(expected);
});
});
});
}(require));
describe('without speed for flash mode', function() {
it('return the video id for current speed', function() {
var expected;
state.isFlashMode.and.returnValue(true);
expected = Initialize.prototype.youtubeId.call(state);
expect(expected).toEqual('abcdefghijkl');
});
});
describe('without speed for youtube html5 mode', function() {
it('return the video id for 1.0x speed', function() {
var expected = Initialize.prototype.youtubeId.call(state);
expect(expected).toEqual('cogebirgzzM');
});
});
describe('speed is absent in the list of video speeds', function() {
it('return the video id for 1.0x speed', function() {
var expected = Initialize.prototype.youtubeId.call(state, '0.0');
expect(expected).toEqual('cogebirgzzM');
});
});
});
describe('setSpeed', function() {
describe('YT', function() {
beforeEach(function() {
state = {
speeds: ['0.25', '0.50', '1.0', '1.50', '2.0'],
storage: jasmine.createSpyObj('storage', ['setItem'])
};
});
it('check mapping', function() {
var map = {
0.75: '0.50',
1.25: '1.50'
};
$.each(map, function(key, expected) {
Initialize.prototype.setSpeed.call(state, key);
expect(state.speed).toBe(parseFloat(expected));
});
});
});
describe('HTML5', function() {
beforeEach(function() {
state = {
speeds: ['0.75', '1.0', '1.25', '1.50', '2.0'],
storage: jasmine.createSpyObj('storage', ['setItem'])
};
});
describe('when 0.75 speed is available', function() {
beforeEach(function() {
Initialize.prototype.setSpeed.call(state, '0.75');
});
it('set new speed', function() {
expect(state.speed).toEqual(0.75);
});
});
describe('when 2.0 speed is available', function() {
beforeEach(function() {
Initialize.prototype.setSpeed.call(state, '2.0');
});
it('set new speed', function() {
expect(state.speed).toEqual(2.0);
});
});
describe('when new speed is not available', function() {
beforeEach(function() {
Initialize.prototype.setSpeed.call(state, '1.75');
});
it('set speed to 1.0x', function() {
expect(state.speed).toEqual(1);
});
});
it('check mapping', function() {
var map = {
0.25: '0.75',
'0.50': '0.75'
};
$.each(map, function(key, expected) {
Initialize.prototype.setSpeed.call(state, key);
expect(state.speed).toBe(parseFloat(expected));
});
});
});
});
describe('setPlayerMode', function() {
beforeEach(function() {
state = {
currentPlayerMode: 'flash'
};
});
it('updates player mode', function() {
var setPlayerMode = Initialize.prototype.setPlayerMode;
setPlayerMode.call(state, 'html5');
expect(state.currentPlayerMode).toBe('html5');
setPlayerMode.call(state, 'flash');
expect(state.currentPlayerMode).toBe('flash');
});
it('sets default mode if passed is not supported', function() {
var setPlayerMode = Initialize.prototype.setPlayerMode;
setPlayerMode.call(state, '77html77');
expect(state.currentPlayerMode).toBe('html5');
});
});
describe('getPlayerMode', function() {
beforeEach(function() {
state = {
currentPlayerMode: 'flash'
};
});
it('returns current player mode', function() {
var getPlayerMode = Initialize.prototype.getPlayerMode,
actual = getPlayerMode.call(state);
expect(actual).toBe(state.currentPlayerMode);
});
});
describe('isFlashMode', function() {
it('returns `true` if player in `flash` mode', function() {
var testState = {
getPlayerMode: jasmine.createSpy().and.returnValue('flash')
},
isFlashMode = Initialize.prototype.isFlashMode,
actual = isFlashMode.call(testState);
expect(actual).toBeTruthy();
});
it('returns `false` if player is not in `flash` mode', function() {
var testState = {
getPlayerMode: jasmine.createSpy().and.returnValue('html5')
},
isFlashMode = Initialize.prototype.isFlashMode,
actual = isFlashMode.call(testState);
expect(actual).toBeFalsy();
});
});
describe('isHtml5Mode', function() {
it('returns `true` if player in `html5` mode', function() {
var testState = {
getPlayerMode: jasmine.createSpy().and.returnValue('html5')
},
isHtml5Mode = Initialize.prototype.isHtml5Mode,
actual = isHtml5Mode.call(testState);
expect(actual).toBeTruthy();
});
it('returns `false` if player is not in `html5` mode', function() {
var testState = {
getPlayerMode: jasmine.createSpy().and.returnValue('flash')
},
isHtml5Mode = Initialize.prototype.isHtml5Mode,
actual = isHtml5Mode.call(testState);
expect(actual).toBeFalsy();
});
});
});

View File

@@ -1,105 +1,99 @@
(function(require) {
'use strict';
import Iterator from '../../../assets/video/public/js/00_iterator.js';
require(
['video/00_iterator.js'],
function(Iterator) {
describe('Iterator', function() {
var list = ['a', 'b', 'c', 'd', 'e'],
iterator;
describe('Iterator', function() {
var list = ['a', 'b', 'c', 'd', 'e'],
iterator;
beforeEach(function() {
iterator = new Iterator(list);
});
beforeEach(function() {
iterator = new Iterator(list);
});
it('size contains correct list length', function() {
expect(iterator.size).toBe(list.length);
expect(iterator.lastIndex).toBe(list.length - 1);
});
it('size contains correct list length', function() {
expect(iterator.size).toBe(list.length);
expect(iterator.lastIndex).toBe(list.length - 1);
});
describe('next', function() {
describe('with passed `index`', function() {
it('returns next item in the list', function() {
expect(iterator.next(2)).toBe('d');
expect(iterator.next(0)).toBe('b');
});
describe('next', function() {
describe('with passed `index`', function() {
it('returns next item in the list', function() {
expect(iterator.next(2)).toBe('d');
expect(iterator.next(0)).toBe('b');
});
it('returns first item if index equal last item', function() {
expect(iterator.next(4)).toBe('a');
});
it('returns first item if index equal last item', function() {
expect(iterator.next(4)).toBe('a');
});
it('returns next item if index is not valid', function() {
expect(iterator.next(-4)).toBe('b'); // index < 0
expect(iterator.next(100)).toBe('c'); // index > size
expect(iterator.next('99')).toBe('d'); // incorrect Type
});
});
describe('without passed `index`', function() {
it('returns next item in the list', function() {
expect(iterator.next()).toBe('b');
expect(iterator.next()).toBe('c');
});
it('returns first item if index equal last item', function() {
expect(iterator.next()).toBe('b');
expect(iterator.next()).toBe('c');
expect(iterator.next()).toBe('d');
expect(iterator.next()).toBe('e');
expect(iterator.next()).toBe('a');
});
});
});
describe('prev', function() {
describe('with passed `index`', function() {
it('returns previous item in the list', function() {
expect(iterator.prev(3)).toBe('c');
expect(iterator.prev(1)).toBe('a');
});
it('returns last item if index equal first item', function() {
expect(iterator.prev(0)).toBe('e');
});
it('returns previous item if index is not valid', function() {
expect(iterator.prev(-4)).toBe('e'); // index < 0
expect(iterator.prev(100)).toBe('d'); // index > size
expect(iterator.prev('99')).toBe('c'); // incorrect Type
});
});
describe('without passed `index`', function() {
it('returns previous item in the list', function() {
expect(iterator.prev()).toBe('e');
expect(iterator.prev()).toBe('d');
});
it('returns last item if index equal first item', function() {
expect(iterator.prev()).toBe('e');
});
});
});
it('returns last item in the list', function() {
expect(iterator.last()).toBe('e');
});
it('returns first item in the list', function() {
expect(iterator.first()).toBe('a');
});
it('isEnd works correctly', function() {
expect(iterator.isEnd()).toBeFalsy();
iterator.next(); // => index 1
expect(iterator.isEnd()).toBeFalsy();
iterator.next(); // => index 2
expect(iterator.isEnd()).toBeFalsy();
iterator.next(); // => index 3
expect(iterator.isEnd()).toBeFalsy();
iterator.next(); // => index 4 == last
expect(iterator.isEnd()).toBeTruthy();
});
it('returns next item if index is not valid', function() {
expect(iterator.next(-4)).toBe('b'); // index < 0
expect(iterator.next(100)).toBe('c'); // index > size
expect(iterator.next('99')).toBe('d'); // incorrect Type
});
});
}(require));
describe('without passed `index`', function() {
it('returns next item in the list', function() {
expect(iterator.next()).toBe('b');
expect(iterator.next()).toBe('c');
});
it('returns first item if index equal last item', function() {
expect(iterator.next()).toBe('b');
expect(iterator.next()).toBe('c');
expect(iterator.next()).toBe('d');
expect(iterator.next()).toBe('e');
expect(iterator.next()).toBe('a');
});
});
});
describe('prev', function() {
describe('with passed `index`', function() {
it('returns previous item in the list', function() {
expect(iterator.prev(3)).toBe('c');
expect(iterator.prev(1)).toBe('a');
});
it('returns last item if index equal first item', function() {
expect(iterator.prev(0)).toBe('e');
});
it('returns previous item if index is not valid', function() {
expect(iterator.prev(-4)).toBe('e'); // index < 0
expect(iterator.prev(100)).toBe('d'); // index > size
expect(iterator.prev('99')).toBe('c'); // incorrect Type
});
});
describe('without passed `index`', function() {
it('returns previous item in the list', function() {
expect(iterator.prev()).toBe('e');
expect(iterator.prev()).toBe('d');
});
it('returns last item if index equal first item', function() {
expect(iterator.prev()).toBe('e');
});
});
});
it('returns last item in the list', function() {
expect(iterator.last()).toBe('e');
});
it('returns first item in the list', function() {
expect(iterator.first()).toBe('a');
});
it('isEnd works correctly', function() {
expect(iterator.isEnd()).toBeFalsy();
iterator.next(); // => index 1
expect(iterator.isEnd()).toBeFalsy();
iterator.next(); // => index 2
expect(iterator.isEnd()).toBeFalsy();
iterator.next(); // => index 3
expect(iterator.isEnd()).toBeFalsy();
iterator.next(); // => index 4 == last
expect(iterator.isEnd()).toBeTruthy();
});
});

View File

@@ -1,270 +1,265 @@
(function(require) {
'use strict';
import Resizer from '../../../assets/video/public/js/00_resizer.js';
import _ from 'underscore';
require(
['video/00_resizer.js', 'underscore'],
function(Resizer, _) {
describe('Resizer', function() {
var html = [
'<div '
+ 'class="rszr-wrapper" '
+ 'style="width:200px; height: 200px;"'
+ '>',
'<div '
+ 'class="rszr-el" '
+ 'style="width:100px; height: 150px;"'
+ '>',
'Content',
'</div>',
'</div>'
].join(''),
config, $container, $element;
describe('Resizer', function() {
var html = [
'<div '
+ 'class="rszr-wrapper" '
+ 'style="width:200px; height: 200px;"'
+ '>',
'<div '
+ 'class="rszr-el" '
+ 'style="width:100px; height: 150px;"'
+ '>',
'Content',
'</div>',
'</div>'
].join(''),
config, $container, $element;
beforeEach(function() {
setFixtures(html);
beforeEach(function() {
setFixtures(html);
$container = $('.rszr-wrapper');
$element = $('.rszr-el');
config = {
container: $container,
element: $element
};
$container = $('.rszr-wrapper');
$element = $('.rszr-el');
config = {
container: $container,
element: $element
};
spyOn(console, 'log');
});
spyOn(console, 'log');
});
it('When Initialize without required parameters, log message is shown',
function() {
// eslint-disable-next-line no-new
new Resizer({ });
expect(console.log).toHaveBeenCalled();
}
);
it('When Initialize without required parameters, log message is shown',
function() {
// eslint-disable-next-line no-new
new Resizer({ });
expect(console.log).toHaveBeenCalled();
}
);
it('`alignByWidthOnly` works correctly', function() {
var resizer = new Resizer(config).alignByWidthOnly(),
expectedWidth = $container.width(),
realWidth = $element.width();
it('`alignByWidthOnly` works correctly', function() {
var resizer = new Resizer(config).alignByWidthOnly(),
expectedWidth = $container.width(),
realWidth = $element.width();
expect(realWidth).toBe(expectedWidth);
});
expect(realWidth).toBe(expectedWidth);
});
it('`alignByHeightOnly` works correctly', function() {
var resizer = new Resizer(config).alignByHeightOnly(),
expectedHeight = $container.height(),
realHeight = $element.height();
it('`alignByHeightOnly` works correctly', function() {
var resizer = new Resizer(config).alignByHeightOnly(),
expectedHeight = $container.height(),
realHeight = $element.height();
expect(realHeight).toBe(expectedHeight);
});
expect(realHeight).toBe(expectedHeight);
});
it('`align` works correctly', function() {
var resizer = new Resizer(config).align(),
expectedHeight = $container.height(),
realHeight = $element.height(),
expectedWidth = 50,
realWidth;
it('`align` works correctly', function() {
var resizer = new Resizer(config).align(),
expectedHeight = $container.height(),
realHeight = $element.height(),
expectedWidth = 50,
realWidth;
// containerRatio >= elementRatio
expect(realHeight).toBe(expectedHeight);
// containerRatio >= elementRatio
expect(realHeight).toBe(expectedHeight);
// containerRatio < elementRatio
$container.width(expectedWidth);
resizer.align();
realWidth = $element.width();
// containerRatio < elementRatio
$container.width(expectedWidth);
resizer.align();
realWidth = $element.width();
expect(realWidth).toBe(expectedWidth);
});
expect(realWidth).toBe(expectedWidth);
});
it('`setMode` works correctly', function() {
var resizer = new Resizer(config).setMode('height'),
expectedHeight = $container.height(),
realHeight = $element.height(),
expectedWidth = 50,
realWidth;
it('`setMode` works correctly', function() {
var resizer = new Resizer(config).setMode('height'),
expectedHeight = $container.height(),
realHeight = $element.height(),
expectedWidth = 50,
realWidth;
// containerRatio >= elementRatio
expect(realHeight).toBe(expectedHeight);
// containerRatio >= elementRatio
expect(realHeight).toBe(expectedHeight);
// containerRatio < elementRatio
$container.width(expectedWidth);
resizer.setMode('width');
realWidth = $element.width();
// containerRatio < elementRatio
$container.width(expectedWidth);
resizer.setMode('width');
realWidth = $element.width();
expect(realWidth).toBe(expectedWidth);
});
expect(realWidth).toBe(expectedWidth);
});
it('`setElement` works correctly', function() {
var $newElement,
expectedHeight;
it('`setElement` works correctly', function() {
var $newElement,
expectedHeight;
$container.append('<div '
+ 'id="Another-el" '
+ 'style="width:100px; height: 150px;"'
+ '>');
$newElement = $('#Another-el');
expectedHeight = $container.height();
$container.append('<div '
+ 'id="Another-el" '
+ 'style="width:100px; height: 150px;"'
+ '>');
$newElement = $('#Another-el');
expectedHeight = $container.height();
new Resizer(config).setElement($newElement).alignByHeightOnly();
expect($element.height()).not.toBe(expectedHeight);
expect($newElement.height()).toBe(expectedHeight);
});
new Resizer(config).setElement($newElement).alignByHeightOnly();
expect($element.height()).not.toBe(expectedHeight);
expect($newElement.height()).toBe(expectedHeight);
});
describe('Callbacks', function() {
var resizer,
spiesList = [];
describe('Callbacks', function() {
var resizer,
spiesList = [];
beforeEach(function() {
var spiesCount = _.range(3);
beforeEach(function() {
var spiesCount = _.range(3);
spiesList = $.map(spiesCount, function() {
return jasmine.createSpy();
});
spiesList = $.map(spiesCount, function() {
return jasmine.createSpy();
});
resizer = new Resizer(config);
});
resizer = new Resizer(config);
});
it('callbacks are called', function() {
$.each(spiesList, function(index, spy) {
resizer.callbacks.add(spy);
});
it('callbacks are called', function() {
$.each(spiesList, function(index, spy) {
resizer.callbacks.add(spy);
});
resizer.align();
resizer.align();
$.each(spiesList, function(index, spy) {
expect(spy).toHaveBeenCalled();
});
});
it('callback called just once', function() {
resizer.callbacks.once(spiesList[0]);
resizer
.align()
.alignByHeightOnly();
expect(spiesList[0].calls.count()).toEqual(1);
});
it('all callbacks are removed', function() {
$.each(spiesList, function(index, spy) {
resizer.callbacks.add(spy);
});
resizer.callbacks.removeAll();
resizer.align();
$.each(spiesList, function(index, spy) {
expect(spy).not.toHaveBeenCalled();
});
});
it('specific callback is removed', function() {
$.each(spiesList, function(index, spy) {
resizer.callbacks.add(spy);
});
resizer.callbacks.remove(spiesList[1]);
resizer.align();
expect(spiesList[1]).not.toHaveBeenCalled();
});
it(
'Error message is shown when wrong argument type is passed',
function() {
var methods = ['add', 'once'],
errorMessage = '[Video info]: TypeError: Argument is not a function.',
arg = {};
spyOn(console, 'error');
$.each(methods, function(index, methodName) {
resizer.callbacks[methodName](arg);
expect(console.error).toHaveBeenCalledWith(errorMessage);
// reset spy
console.log.calls.reset();
});
});
});
describe('Delta', function() {
var resizer;
beforeEach(function() {
resizer = new Resizer(config);
});
it('adding delta align correctly by height', function() {
var delta = 100,
expectedHeight = $container.height() + delta,
realHeight;
resizer
.delta.add(delta, 'height')
.setMode('height');
realHeight = $element.height();
expect(realHeight).toBe(expectedHeight);
});
it('adding delta align correctly by width', function() {
var delta = 100,
expectedWidth = $container.width() + delta,
realWidth;
resizer
.delta.add(delta, 'width')
.setMode('width');
realWidth = $element.width();
expect(realWidth).toBe(expectedWidth);
});
it('substract delta align correctly by height', function() {
var delta = 100,
expectedHeight = $container.height() - delta,
realHeight;
resizer
.delta.substract(delta, 'height')
.setMode('height');
realHeight = $element.height();
expect(realHeight).toBe(expectedHeight);
});
it('substract delta align correctly by width', function() {
var delta = 100,
expectedWidth = $container.width() - delta,
realWidth;
resizer
.delta.substract(delta, 'width')
.setMode('width');
realWidth = $element.width();
expect(realWidth).toBe(expectedWidth);
});
it('reset delta', function() {
var delta = 100,
expectedWidth = $container.width(),
realWidth;
resizer
.delta.substract(delta, 'width')
.delta.reset()
.setMode('width');
realWidth = $element.width();
expect(realWidth).toBe(expectedWidth);
});
});
$.each(spiesList, function(index, spy) {
expect(spy).toHaveBeenCalled();
});
});
}(require));
it('callback called just once', function() {
resizer.callbacks.once(spiesList[0]);
resizer
.align()
.alignByHeightOnly();
expect(spiesList[0].calls.count()).toEqual(1);
});
it('all callbacks are removed', function() {
$.each(spiesList, function(index, spy) {
resizer.callbacks.add(spy);
});
resizer.callbacks.removeAll();
resizer.align();
$.each(spiesList, function(index, spy) {
expect(spy).not.toHaveBeenCalled();
});
});
it('specific callback is removed', function() {
$.each(spiesList, function(index, spy) {
resizer.callbacks.add(spy);
});
resizer.callbacks.remove(spiesList[1]);
resizer.align();
expect(spiesList[1]).not.toHaveBeenCalled();
});
it(
'Error message is shown when wrong argument type is passed',
function() {
var methods = ['add', 'once'],
errorMessage = '[Video info]: TypeError: Argument is not a function.',
arg = {};
spyOn(console, 'error');
$.each(methods, function(index, methodName) {
resizer.callbacks[methodName](arg);
expect(console.error).toHaveBeenCalledWith(errorMessage);
// reset spy
console.log.calls.reset();
});
});
});
describe('Delta', function() {
var resizer;
beforeEach(function() {
resizer = new Resizer(config);
});
it('adding delta align correctly by height', function() {
var delta = 100,
expectedHeight = $container.height() + delta,
realHeight;
resizer
.delta.add(delta, 'height')
.setMode('height');
realHeight = $element.height();
expect(realHeight).toBe(expectedHeight);
});
it('adding delta align correctly by width', function() {
var delta = 100,
expectedWidth = $container.width() + delta,
realWidth;
resizer
.delta.add(delta, 'width')
.setMode('width');
realWidth = $element.width();
expect(realWidth).toBe(expectedWidth);
});
it('substract delta align correctly by height', function() {
var delta = 100,
expectedHeight = $container.height() - delta,
realHeight;
resizer
.delta.substract(delta, 'height')
.setMode('height');
realHeight = $element.height();
expect(realHeight).toBe(expectedHeight);
});
it('substract delta align correctly by width', function() {
var delta = 100,
expectedWidth = $container.width() - delta,
realWidth;
resizer
.delta.substract(delta, 'width')
.setMode('width');
realWidth = $element.width();
expect(realWidth).toBe(expectedWidth);
});
it('reset delta', function() {
var delta = 100,
expectedWidth = $container.width(),
realWidth;
resizer
.delta.substract(delta, 'width')
.delta.reset()
.setMode('width');
realWidth = $element.width();
expect(realWidth).toBe(expectedWidth);
});
});
});

View File

@@ -1,67 +1,63 @@
(function(require) {
require(
['video/00_sjson.js'],
function(Sjson) {
describe('Sjson', function() {
var data = jasmine.stubbedCaption,
sjson;
var videoStops = [0, 3120, 6270, 8490, 21620, 24920];
var OUT_OF_BOUNDS_STOP = 10024920;
import Sjson from '../../../assets/video/public/js/00_sjson.js';
beforeEach(function() {
sjson = new Sjson(data);
});
describe('Sjson', function() {
var data = jasmine.stubbedCaption,
sjson;
var videoStops = [0, 3120, 6270, 8490, 21620, 24920];
var OUT_OF_BOUNDS_STOP = 10024920;
it('returns captions', function() {
expect(sjson.getCaptions()).toEqual(data.text);
});
beforeEach(function() {
sjson = new Sjson(data);
});
it('returns start times', function() {
expect(sjson.getStartTimes()).toEqual(data.start);
});
it('returns captions', function() {
expect(sjson.getCaptions()).toEqual(data.text);
});
it('returns correct length', function() {
expect(sjson.getSize()).toEqual(data.text.length);
});
it('returns start times', function() {
expect(sjson.getStartTimes()).toEqual(data.start);
});
it('search returns a correct caption index', function() {
expect(sjson.search(videoStops[0])).toEqual(0);
expect(sjson.search(videoStops[1])).toEqual(1);
expect(sjson.search(videoStops[2])).toEqual(2);
expect(sjson.search(videoStops[3])).toEqual(2);
expect(sjson.search(videoStops[4])).toEqual(4);
expect(sjson.search(videoStops[5])).toEqual(5);
});
it('returns correct length', function() {
expect(sjson.getSize()).toEqual(data.text.length);
});
it('search returns the last entry for a value outside the bounds of the array', function() {
expect(sjson.search(OUT_OF_BOUNDS_STOP)).toEqual(sjson.getCaptions().length - 1);
});
it('search returns a correct caption index', function() {
expect(sjson.search(videoStops[0])).toEqual(0);
expect(sjson.search(videoStops[1])).toEqual(1);
expect(sjson.search(videoStops[2])).toEqual(2);
expect(sjson.search(videoStops[3])).toEqual(2);
expect(sjson.search(videoStops[4])).toEqual(4);
expect(sjson.search(videoStops[5])).toEqual(5);
});
it('search returns the first entry for a negative index in the array', function() {
expect(sjson.search(-1)).toEqual(0);
});
it('search returns the last entry for a value outside the bounds of the array', function() {
expect(sjson.search(OUT_OF_BOUNDS_STOP)).toEqual(sjson.getCaptions().length - 1);
});
it('search only searches through a subrange of times if start / end times are specified', function() {
var start = videoStops[2] - 100;
var end = videoStops[5] - 100;
var results = sjson.filter(start, end);
var expectedLength = results.captions.length - 1;
it('search returns the first entry for a negative index in the array', function() {
expect(sjson.search(-1)).toEqual(0);
});
expect(sjson.search(videoStops[0], start, end)).toEqual(0);
expect(sjson.search(videoStops[1], start, end)).toEqual(0);
expect(sjson.search(videoStops[2], start, end)).toEqual(0);
expect(sjson.search(videoStops[3], start, end)).toEqual(0);
expect(sjson.search(OUT_OF_BOUNDS_STOP, start, end)).toEqual(expectedLength);
});
it('search only searches through a subrange of times if start / end times are specified', function() {
var start = videoStops[2] - 100;
var end = videoStops[5] - 100;
var results = sjson.filter(start, end);
var expectedLength = results.captions.length - 1;
it('filters results correctly given a start and end time', function() {
var start = videoStops[1] - 100;
var end = videoStops[4] - 100;
var results = sjson.filter(start, end);
expect(sjson.search(videoStops[0], start, end)).toEqual(0);
expect(sjson.search(videoStops[1], start, end)).toEqual(0);
expect(sjson.search(videoStops[2], start, end)).toEqual(0);
expect(sjson.search(videoStops[3], start, end)).toEqual(0);
expect(sjson.search(OUT_OF_BOUNDS_STOP, start, end)).toEqual(expectedLength);
});
expect(results.start.length).toEqual(3);
expect(results.captions.length).toEqual(3);
});
});
});
}(require));
it('filters results correctly given a start and end time', function() {
var start = videoStops[1] - 100;
var end = videoStops[4] - 100;
var results = sjson.filter(start, end);
expect(results.start.length).toEqual(3);
expect(results.captions.length).toEqual(3);
});
});

View File

@@ -67,6 +67,11 @@
spyOnEvent($nextButton, 'click');
expect('click').not.toHaveBeenTriggeredOn($nextButton);
// Spy on window.parent.postMessage and make it trigger a click
spyOn(window.parent, 'postMessage').and.callFake(function() {
$nextButton.trigger('click');
});
state.el.trigger('ended');
jasmine.clock().tick(2);
@@ -101,7 +106,7 @@
state.el.trigger('ended');
jasmine.clock().tick(2);
// still not auto-clicked
// still not auto-clicked (auto-advance is disabled)
expect('click').not.toHaveBeenTriggeredOn($nextButton);
});
});

View File

@@ -447,10 +447,10 @@
$(this).trigger('mousedown');
expect(state.videoCaption.captionMouseDown).toHaveBeenCalled();
$(this).trigger('focus');
$(this).trigger('focusin');
expect(state.videoCaption.captionFocus).toHaveBeenCalled();
$(this).trigger('blur');
$(this).trigger('focusout');
expect(state.videoCaption.captionBlur).toHaveBeenCalled();
$(this).trigger('keydown');
@@ -1245,7 +1245,7 @@
beforeEach(function() {
state.videoCaption.isMouseFocus = false;
$('.subtitles li span[data-index=0]').trigger(
jQuery.Event('focus')
jQuery.Event('focusin')
);
});
@@ -1353,25 +1353,25 @@
state = jasmine.initializePlayer();
Caption = state.videoCaption;
})
it('not shown when captions are not ai generated', () => {
Caption.updateGoogleDisclaimer(BASE_CAPTIONS)
expect(state.shouldShowGoogleDisclaimer).toBe(false);
expect(Caption.shouldShowGoogleDisclaimer).toBe(false);
});
it('not shown when captions are not generated by gcp', () => {
Caption.updateGoogleDisclaimer(makeCaptions('someoneElse'))
expect(state.shouldShowGoogleDisclaimer).toBe(false);
expect(Caption.shouldShowGoogleDisclaimer).toBe(false);
});
it('shown when captions are generated by gcp', () => {
Caption.updateGoogleDisclaimer(makeCaptions('gcp'))
expect(state.shouldShowGoogleDisclaimer).toBe(true);
expect(Caption.shouldShowGoogleDisclaimer).toBe(true);
});
it('shown when captions have no provider', () => {
Caption.updateGoogleDisclaimer(makeCaptions())
expect(state.shouldShowGoogleDisclaimer).toBe(true);
expect(Caption.shouldShowGoogleDisclaimer).toBe(true);
});
});
});

View File

@@ -57,7 +57,7 @@
return isFinite(duration) && duration > 0 && isFinite(state.videoPlayer.startTime);
}).then(function() {
expectedValue = $('.video-controls').find('.vidtime');
expect(expectedValue).toHaveText('0:10 / 1:00');
expect(expectedValue).toHaveText('0:00 / 1:00');
expectedValue = sliderEl.slider('option', 'value');
expect(expectedValue).toBe(10);
@@ -85,7 +85,7 @@
&& isFinite(state.videoPlayer.startTime);
}).then(function() {
expectedValue = $('.video-controls').find('.vidtime');
expect(expectedValue).toHaveText('0:15 / 1:00');
expect(expectedValue).toHaveText('0:05 / 1:00');
expectedValue = sliderEl.slider('option', 'value');
expect(expectedValue).toBe(15);
@@ -115,7 +115,7 @@
&& isFinite(state.videoPlayer.startTime);
}).then(function() {
expectedValue = $('.video-controls').find('.vidtime');
expect(expectedValue).toHaveText('0:10 / 1:00');
expect(expectedValue).toHaveText('0:00 / 1:00');
expectedValue = sliderEl.slider('option', 'value');
expect(expectedValue).toBe(10);
@@ -145,7 +145,7 @@
&& isFinite(state.videoPlayer.startTime);
}).then(function() {
expectedValue = $('.video-controls').find('.vidtime');
expect(expectedValue).toHaveText('0:10 / 1:00');
expect(expectedValue).toHaveText('0:00 / 1:00');
expectedValue = sliderEl.slider('option', 'value');
expect(expectedValue).toBe(10);
@@ -175,7 +175,7 @@
&& isFinite(state.videoPlayer.startTime);
}).then(function() {
expectedValue = $('.video-controls').find('.vidtime');
expect(expectedValue).toHaveText('0:10 / 1:00');
expect(expectedValue).toHaveText('0:00 / 1:00');
expectedValue = sliderEl.slider('option', 'value');
expect(expectedValue).toBe(10);
@@ -187,22 +187,29 @@
describe('constructor with end-time', function() {
it('displays the correct time when startTime and endTime are specified', function(done) {
let duration;
state = jasmine.initializePlayer({
start: 10,
end: 20
});
spyOn(state.videoPlayer, 'duration').and.returnValue(60);
state.videoControl.updateVcrVidTime({
time: 15,
duration: 60
});
jasmine.waitUntil(function() {
var expectedValue = $('.video-controls').find('.vidtime');
return expectedValue.text().indexOf('0:05 / 0:20') !== -1; // Expecting 15 seconds - 10 seconds = 5 seconds
duration = state.videoPlayer.duration();
return isFinite(duration) && duration > 0 && isFinite(state.videoPlayer.startTime);
}).then(function() {
expect($('.video-controls').find('.vidtime')).toHaveText('0:05 / 0:20');
// Update the video control time display
state.videoControl.updateVcrVidTime({
time: 15,
duration: 60
});
jasmine.waitUntil(function() {
var expectedValue = $('.video-controls').find('.vidtime');
return expectedValue.text().indexOf('0:05 / 0:10') !== -1; // Expecting 15 seconds - 10 seconds = 5 seconds
}).then(function() {
expect($('.video-controls').find('.vidtime')).toHaveText('0:05 / 0:10');
}).always(done);
}).always(done);
});
@@ -380,7 +387,7 @@
&& isFinite(state.videoPlayer.startTime);
}).then(function() {
expectedValue = $('.video-controls').find('.vidtime');
expect(expectedValue).toHaveText('0:10 / 0:20');
expect(expectedValue).toHaveText('0:00 / 0:10');
expectedValue = sliderEl.slider('option', 'value');
expect(expectedValue).toBe(10);
@@ -411,7 +418,7 @@
&& isFinite(state.videoPlayer.startTime);
}).then(function() {
expectedValue = $('.video-controls').find('.vidtime');
expect(expectedValue).toHaveText('0:15 / 0:20');
expect(expectedValue).toHaveText('0:05 / 0:10');
expectedValue = sliderEl.slider('option', 'value');
expect(expectedValue).toBe(15);
@@ -442,7 +449,7 @@
&& isFinite(state.videoPlayer.startTime);
}).then(function() {
expectedValue = $('.video-controls').find('.vidtime');
expect(expectedValue).toHaveText('0:10 / 0:20');
expect(expectedValue).toHaveText('0:00 / 0:10');
expectedValue = sliderEl.slider('option', 'value');
expect(expectedValue).toBe(10);
@@ -473,7 +480,7 @@
&& isFinite(state.videoPlayer.startTime);
}).then(function() {
expectedValue = $('.video-controls').find('.vidtime');
expect(expectedValue).toHaveText('0:10 / 0:20');
expect(expectedValue).toHaveText('0:00 / 0:10');
expectedValue = sliderEl.slider('option', 'value');
expect(expectedValue).toBe(10);
@@ -504,7 +511,7 @@
&& isFinite(state.videoPlayer.startTime);
}).then(function() {
expectedValue = $('.video-controls').find('.vidtime');
expect(expectedValue).toHaveText('0:10 / 0:20');
expect(expectedValue).toHaveText('0:00 / 0:10');
expectedValue = sliderEl.slider('option', 'value');
expect(expectedValue).toBe(10);

View File

@@ -11,7 +11,6 @@ import '../helper.js';
beforeEach(function() {
spyOn(Logger, 'log');
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
});
afterEach(function() {
@@ -32,221 +31,27 @@ import '../helper.js';
});
});
it('can emit "play_video" event when emitPlayVideoEvent is true', function() {
state.videoEventsPlugin.emitPlayVideoEvent = true;
state.el.trigger('play');
expect(Logger.log).toHaveBeenCalledWith('play_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeFalsy();
});
it('can not emit "play_video" event when emitPlayVideoEvent is false', function() {
state.videoEventsPlugin.emitPlayVideoEvent = false;
state.el.trigger('play');
expect(Logger.log).not.toHaveBeenCalled();
});
it('can emit "pause_video" event', function() {
state.el.trigger('pause');
expect(Logger.log).toHaveBeenCalledWith('pause_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
});
it('can emit "complete_video" event when video is marked as complete', function() {
state.el.trigger('complete');
expect(Logger.log).toHaveBeenCalledWith('complete_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
});
it('can emit "speed_change_video" event', function() {
state.el.trigger('speedchange', ['2.0', '1.0']);
expect(Logger.log).toHaveBeenCalledWith('speed_change_video', {
id: 'id',
code: this.code,
current_time: 10,
old_speed: '1.0',
new_speed: '2.0',
duration: this.duration
});
});
it('can emit "seek_video" event', function() {
state.el.trigger('seek', [1, 0, 'any']);
expect(Logger.log).toHaveBeenCalledWith('seek_video', {
id: 'id',
code: this.code,
old_time: 0,
new_time: 1,
type: 'any',
duration: this.duration
});
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
});
it('can emit "play_video" event after "seek_video" event ', function() {
state.videoEventsPlugin.emitPlayVideoEvent = false;
state.el.trigger('seek', [1, 0, 'any']);
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
});
it('can emit "stop_video" event', function() {
state.el.trigger('ended');
expect(Logger.log).toHaveBeenCalledWith('stop_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
Logger.log.calls.reset();
state.el.trigger('stop');
expect(Logger.log).toHaveBeenCalledWith('stop_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
});
it('can emit "skip_video" event', function() {
state.el.trigger('skip', [false]);
expect(Logger.log).toHaveBeenCalledWith('skip_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
});
it('can emit "do_not_show_again_video" event', function() {
state.el.trigger('skip', [true]);
expect(Logger.log).toHaveBeenCalledWith('do_not_show_again_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
});
it('can emit "edx.video.language_menu.shown" event', function() {
state.el.trigger('language_menu:show');
expect(Logger.log).toHaveBeenCalledWith('edx.video.language_menu.shown', {
id: 'id',
code: this.code,
duration: this.duration
});
});
it('can emit "edx.video.language_menu.hidden" event', function() {
state.el.trigger('language_menu:hide');
expect(Logger.log).toHaveBeenCalledWith('edx.video.language_menu.hidden', {
id: 'id',
code: this.code,
language: 'en',
duration: this.duration
});
});
it('can emit "show_transcript" event', function() {
state.el.trigger('transcript:show');
expect(Logger.log).toHaveBeenCalledWith('show_transcript', {
id: 'id',
code: this.code,
current_time: 10,
duration: this.duration
});
});
it('can emit "hide_transcript" event', function() {
state.el.trigger('transcript:hide');
expect(Logger.log).toHaveBeenCalledWith('hide_transcript', {
id: 'id',
code: this.code,
current_time: 10,
duration: this.duration
});
});
it('can emit "edx.video.closed_captions.shown" event', function() {
state.el.trigger('captions:show');
expect(Logger.log).toHaveBeenCalledWith('edx.video.closed_captions.shown', {
id: 'id',
code: this.code,
current_time: 10,
duration: this.duration
});
});
it('can emit "edx.video.closed_captions.hidden" event', function() {
state.el.trigger('captions:hide');
expect(Logger.log).toHaveBeenCalledWith('edx.video.closed_captions.hidden', {
id: 'id',
code: this.code,
current_time: 10,
duration: this.duration
});
});
it('can destroy itself', function() {
var plugin = state.videoEventsPlugin;
spyOn($.fn, 'off').and.callThrough();
state.videoEventsPlugin.destroy();
expect(state.videoEventsPlugin).toBeUndefined();
expect($.fn.off).toHaveBeenCalledWith({
ready: plugin.onReady,
play: plugin.onPlay,
pause: plugin.onPause,
complete: plugin.onComplete,
'ended stop': plugin.onEnded,
seek: plugin.onSeek,
skip: plugin.onSkip,
speedchange: plugin.onSpeedChange,
autoadvancechange: plugin.onAutoAdvanceChange,
'language_menu:show': plugin.onShowLanguageMenu,
'language_menu:hide': plugin.onHideLanguageMenu,
'transcript:show': plugin.onShowTranscript,
'transcript:hide': plugin.onHideTranscript,
'captions:show': plugin.onShowCaptions,
'captions:hide': plugin.onHideCaptions,
destroy: plugin.destroy
});
});
describe('getCurrentTime method', function() {
it('returns current time adjusted by startTime if video starts from a subsection', function() {
spyOn(state.videoPlayer, 'currentTime', 'get').and.returnValue(120);
state.config.startTime = 30;
state.videoPlayer.currentTime = 120;
state.config.startTime = 30;
expect(state.videoEventsPlugin.getCurrentTime()).toBe(90); // 120 - 30 = 90
});
it('returns 0 if currentTime is undefined', function() {
spyOn(state.videoPlayer, 'currentTime', 'get').and.returnValue(undefined);
state.config.startTime = 30; // Start time is irrelevant since current time is undefined
state.videoPlayer.currentTime = undefined;
expect(state.videoEventsPlugin.getCurrentTime()).toBe(0);
});
it('returns unadjusted current time if startTime is not defined', function() {
spyOn(state.videoPlayer, 'currentTime', 'get').and.returnValue(60);
state.videoPlayer.currentTime = 60;
expect(state.videoEventsPlugin.getCurrentTime()).toBe(60); // Returns current time as is
});
});
describe('log method', function() {
it('logs event with adjusted duration when startTime and endTime are defined', function() {
state.config.startTime = 30;
state.config.endTime = 150;
@@ -261,6 +66,212 @@ import '../helper.js';
});
});
it('can emit "play_video" event when emitPlayVideoEvent is true', function() {
state.videoEventsPlugin.emitPlayVideoEvent = true;
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
state.el.trigger('play');
expect(Logger.log).toHaveBeenCalledWith('play_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeFalsy();
});
it('can not emit "play_video" event when emitPlayVideoEvent is false', function() {
state.videoEventsPlugin.emitPlayVideoEvent = false;
state.el.trigger('play');
expect(Logger.log).not.toHaveBeenCalled();
});
it('can emit "pause_video" event', function() {
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
state.el.trigger('pause');
expect(Logger.log).toHaveBeenCalledWith('pause_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
});
it('can emit "complete_video" event when video is marked as complete', function() {
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
state.el.trigger('complete');
expect(Logger.log).toHaveBeenCalledWith('complete_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
});
it('can emit "speed_change_video" event', function() {
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
state.el.trigger('speedchange', ['2.0', '1.0']);
expect(Logger.log).toHaveBeenCalledWith('speed_change_video', {
id: 'id',
code: this.code,
current_time: 10,
old_speed: '1.0',
new_speed: '2.0',
duration: this.duration
});
});
it('can emit "seek_video" event', function() {
state.el.trigger('seek', [1, 0, 'any']);
expect(Logger.log).toHaveBeenCalledWith('seek_video', {
id: 'id',
code: this.code,
old_time: 0,
new_time: 1,
type: 'any',
duration: this.duration
});
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
});
it('can emit "play_video" event after "seek_video" event ', function() {
state.videoEventsPlugin.emitPlayVideoEvent = false;
state.el.trigger('seek', [1, 0, 'any']);
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
});
it('can emit "stop_video" event', function() {
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
state.el.trigger('ended');
expect(Logger.log).toHaveBeenCalledWith('stop_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
Logger.log.calls.reset();
state.el.trigger('stop');
expect(Logger.log).toHaveBeenCalledWith('stop_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
});
it('can emit "skip_video" event', function() {
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
state.el.trigger('skip', [false]);
expect(Logger.log).toHaveBeenCalledWith('skip_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
});
it('can emit "do_not_show_again_video" event', function() {
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
state.el.trigger('skip', [true]);
expect(Logger.log).toHaveBeenCalledWith('do_not_show_again_video', {
id: 'id',
code: this.code,
currentTime: 10,
duration: this.duration
});
});
it('can emit "edx.video.language_menu.shown" event', function() {
state.el.trigger('language_menu:show');
expect(Logger.log).toHaveBeenCalledWith('edx.video.language_menu.shown', {
id: 'id',
code: this.code,
duration: this.duration
});
});
it('can emit "edx.video.language_menu.hidden" event', function() {
state.el.trigger('language_menu:hide');
expect(Logger.log).toHaveBeenCalledWith('edx.video.language_menu.hidden', {
id: 'id',
code: this.code,
language: 'en',
duration: this.duration
});
});
it('can emit "show_transcript" event', function() {
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
state.el.trigger('transcript:show');
expect(Logger.log).toHaveBeenCalledWith('show_transcript', {
id: 'id',
code: this.code,
current_time: 10,
duration: this.duration
});
});
it('can emit "hide_transcript" event', function() {
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
state.el.trigger('transcript:hide');
expect(Logger.log).toHaveBeenCalledWith('hide_transcript', {
id: 'id',
code: this.code,
current_time: 10,
duration: this.duration
});
});
it('can emit "edx.video.closed_captions.shown" event', function() {
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
state.el.trigger('captions:show');
expect(Logger.log).toHaveBeenCalledWith('edx.video.closed_captions.shown', {
id: 'id',
code: this.code,
current_time: 10,
duration: this.duration
});
});
it('can emit "edx.video.closed_captions.hidden" event', function() {
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
state.el.trigger('captions:hide');
expect(Logger.log).toHaveBeenCalledWith('edx.video.closed_captions.hidden', {
id: 'id',
code: this.code,
current_time: 10,
duration: this.duration
});
});
it('can destroy itself', function() {
var plugin = state.videoEventsPlugin;
spyOn($.fn, 'off').and.callThrough();
state.videoEventsPlugin.destroy();
expect(state.videoEventsPlugin).toBeUndefined();
expect($.fn.off).toHaveBeenCalledWith({
ready: plugin.onReady,
play: plugin.onPlay,
pause: plugin.onPause,
complete: plugin.onComplete,
'ended stop': plugin.onEnded,
seek: plugin.onSeek,
skip: plugin.onSkip,
speedchange: plugin.onSpeedChange,
autoadvancechange: plugin.onAutoAdvanceChange,
'language_menu:show': plugin.onShowLanguageMenu,
'language_menu:hide': plugin.onHideLanguageMenu,
'transcript:show': plugin.onShowTranscript,
'transcript:hide': plugin.onHideTranscript,
'captions:show': plugin.onShowCaptions,
'captions:hide': plugin.onHideCaptions,
destroy: plugin.destroy
});
});
it('logs event with full duration when startTime and endTime are not defined', function() {
state.config.startTime = undefined;
state.config.endTime = undefined;

View File

@@ -18,7 +18,8 @@
jasmine.stubRequests();
loadFixtures('video_html5.html');
state = new Video('#example');
let runtime = jasmine.createSpyObj('TestRuntime', ['handlerUrl']);
state = new Video(runtime, '#example');
spyOnEvent(state.el, 'mousemove');
spyOn(state.focusGrabber, 'disableFocusGrabber').and.callThrough();

View File

@@ -1,12 +1,12 @@
/* global YT */
// eslint-disable-next-line no-shadow-restricted-names
(function(require, define, undefined) {
import VideoPlayer from "../../../assets/video/public/js/03_video_player.js";
import HLS from 'hls';
import _ from 'underscore';
(function() {
'use strict';
require(
['video/03_video_player.js', 'hls', 'underscore'],
function(VideoPlayer, HLS, _) {
describe('VideoPlayer', function() {
var STATUS = window.STATUS,
state,
@@ -1097,5 +1097,5 @@
});
});
});
});
}(require, define));
}).call(this);

View File

@@ -153,8 +153,7 @@
spyOn(state.videoPlayer, 'onSlideSeek').and.callThrough();
});
// Disabled 12/30/13 due to flakiness in master
xit('freeze the slider', function() {
it('freeze the slider', function() {
state.videoProgressSlider.onSlide(
jQuery.Event('slide'), {value: 20}
);
@@ -162,8 +161,7 @@
expect(state.videoProgressSlider.frozen).toBeTruthy();
});
// Disabled 12/30/13 due to flakiness in master
xit('trigger seek event', function() {
it('trigger seek event', function() {
state.videoProgressSlider.onSlide(
jQuery.Event('slide'), {value: 20}
);
@@ -185,8 +183,7 @@
jasmine.clock().uninstall();
});
// Disabled 12/30/13 due to flakiness in master
xit('freeze the slider', function() {
it('freeze the slider', function() {
state.videoProgressSlider.onStop(
jQuery.Event('stop'), {value: 20}
);
@@ -194,8 +191,7 @@
expect(state.videoProgressSlider.frozen).toBeTruthy();
});
// Disabled 12/30/13 due to flakiness in master
xit('trigger seek event', function() {
it('trigger seek event', function() {
state.videoProgressSlider.onStop(
jQuery.Event('stop'), {value: 20}
);
@@ -203,8 +199,7 @@
expect(state.videoPlayer.onSlideSeek).toHaveBeenCalled();
});
// Disabled 12/30/13 due to flakiness in master
xit('set timeout to unfreeze the slider', function() {
it('set timeout to unfreeze the slider', function() {
state.videoProgressSlider.onStop(
jQuery.Event('stop'), {value: 20}
);
@@ -259,14 +254,14 @@
spyOnEvent(state.videoProgressSlider.handle, 'focus');
spyOn(state.videoProgressSlider, 'notifyThroughHandleEnd')
.and.callThrough();
});
it('params.end = true', function() {
state.videoProgressSlider.notifyThroughHandleEnd({end: true});
expect(state.videoProgressSlider.handle.attr('title'))
.toBe('Video ended');
state.videoProgressSlider.handle.trigger('focus');
expect('focus').toHaveBeenTriggeredOn(
state.videoProgressSlider.handle
);

View File

@@ -1,4 +1,4 @@
import * as Time from 'time.js';
import * as Time from '../../../assets/video/public/js/utils/time.js';
// eslint-disable-next-line no-shadow-restricted-names
(function(undefined) {

View File

@@ -85,7 +85,8 @@
+ 'entry has focus', function() {
// Open speed meenu. Focus is on last speed entry.
$speedControl.trigger(keyPressEvent(KEY.ENTER));
$speedControl.mouseenter().mouseleave();
$speedControl.mouseenter();
expect(speedEntries.last()).toBeFocused();
expect($speedControl).toHaveClass('is-opened');
});

View File

@@ -1,86 +1,81 @@
// eslint-disable-next-line no-shadow-restricted-names
(function(require, define, undefined) {
require(
['video/00_video_storage.js'],
function(VideoStorage) {
describe('VideoStorage', function() {
var namespace = 'test_storage',
id = 'video_id';
import VideoStorage from '../../../assets/video/public/js/00_video_storage.js';
afterEach(function() {
VideoStorage(namespace, id).clear();
});
describe('VideoStorage', function() {
var namespace = 'test_storage',
id = 'video_id';
describe('initialize', function() {
it('with namespace and id', function() {
var storage = VideoStorage(namespace, id);
afterEach(function() {
VideoStorage(namespace, id).clear();
});
expect(window[namespace]).toBeDefined();
expect(window[namespace][id]).toBeDefined();
});
describe('initialize', function() {
it('with namespace and id', function() {
var storage = VideoStorage(namespace, id);
it('without namespace and id', function() {
spyOn(Number.prototype, 'toString').and.returnValue('0.abcdedg');
var storage = VideoStorage();
expect(window.VideoStorage).toBeDefined();
expect(window.VideoStorage.abcdedg).toBeDefined();
});
});
describe('methods: ', function() {
var data, storage;
beforeEach(function() {
data = {
item_2: 'value_2'
};
data[id] = {
item_1: 'value_1'
};
window[namespace] = data;
storage = VideoStorage(namespace, id);
});
it('setItem', function() {
var expected = $.extend(true, {}, data, {item_4: 'value_4'});
expected[id].item_3 = 'value_3';
storage.setItem('item_3', 'value_3', true);
storage.setItem('item_4', 'value_4');
expect(window[namespace]).toEqual(expected);
});
it('getItem', function() {
// eslint-disable-next-line no-shadow
var data = window[namespace],
getItem = storage.getItem;
expect(getItem('item_1', true)).toBe(data[id].item_1);
expect(getItem('item_2')).toBe(data.item_2);
expect(getItem('item_3')).toBeUndefined();
});
it('removeItem', function() {
// eslint-disable-next-line no-shadow
var data = window[namespace],
removeItem = storage.removeItem;
removeItem('item_1', true);
removeItem('item_2');
expect(data[id].item_1).toBeUndefined();
expect(data.item_2).toBeUndefined();
});
it('clear', function() {
var expected = {};
expected[id] = {};
storage.clear();
expect(window[namespace]).toEqual(expected);
});
});
});
expect(window[namespace]).toBeDefined();
expect(window[namespace][id]).toBeDefined();
});
}(require, define));
it('without namespace and id', function() {
spyOn(Number.prototype, 'toString').and.returnValue('0.abcdedg');
var storage = VideoStorage();
expect(window.VideoStorage).toBeDefined();
expect(window.VideoStorage.abcdedg).toBeDefined();
});
});
describe('methods: ', function() {
var data, storage;
beforeEach(function() {
data = {
item_2: 'value_2'
};
data[id] = {
item_1: 'value_1'
};
window[namespace] = data;
storage = VideoStorage(namespace, id);
});
it('setItem', function() {
var expected = $.extend(true, {}, data, {item_4: 'value_4'});
expected[id].item_3 = 'value_3';
storage.setItem('item_3', 'value_3', true);
storage.setItem('item_4', 'value_4');
expect(window[namespace]).toEqual(expected);
});
it('getItem', function() {
// eslint-disable-next-line no-shadow
var data = window[namespace],
getItem = storage.getItem;
expect(getItem('item_1', true)).toBe(data[id].item_1);
expect(getItem('item_2')).toBe(data.item_2);
expect(getItem('item_3')).toBeUndefined();
});
it('removeItem', function() {
// eslint-disable-next-line no-shadow
var data = window[namespace],
removeItem = storage.removeItem;
removeItem('item_1', true);
removeItem('item_2');
expect(data[id].item_1).toBeUndefined();
expect(data.item_2).toBeUndefined();
});
it('clear', function() {
var expected = {};
expected[id] = {};
storage.clear();
expect(window[namespace]).toEqual(expected);
});
});
});

View File

@@ -1,59 +0,0 @@
(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));

View File

@@ -1,83 +0,0 @@
(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)) {
// eslint-disable-next-line prefer-spread
return this.initialize.apply(this, arguments);
}
};
/**
* Returns new constructor that inherits form the current constructor.
* @static
* @param {Object} protoProps The object containing which will be added to
* the prototype.
* @return {Object}
*/
Component.extend = function(protoProps, staticProps) {
var Parent = this,
Child = function() {
if ($.isFunction(this.initialize)) {
// eslint-disable-next-line prefer-spread
return this.initialize.apply(this, arguments);
}
};
// Inherit methods and properties from the Parent prototype.
Child.prototype = inherit(Parent.prototype);
Child.constructor = Parent;
// Provide access to parent's methods and properties
Child.__super__ = Parent.prototype;
// Extends inherited methods and properties by methods/properties
// passed as argument.
if (protoProps) {
$.extend(Child.prototype, protoProps);
}
// Inherit static methods and properties
$.extend(Child, Parent, staticProps);
return Child;
};
return Component;
});
}(RequireJS.define));

View File

@@ -1,40 +0,0 @@
(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));

View File

@@ -1,90 +0,0 @@
(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));

View File

@@ -1,238 +0,0 @@
(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;
// eslint-disable-next-line no-shadow
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() {
// eslint-disable-next-line no-multi-assign
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));

View File

@@ -1,115 +0,0 @@
(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));

View File

@@ -1,103 +0,0 @@
(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));

View File

@@ -1,845 +0,0 @@
/* eslint-disable no-console, no-param-reassign */
/**
* @file Initialize module works with the JSON config, and sets up various
* settings, parameters, variables. After all setup actions are performed, it
* 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() {
_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);
}
// eslint-disable-next-line no-shadow
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.
// eslint-disable-next-line no-shadow
function bindTo(methodsDict, obj, context, rewrite) {
$.each(methodsDict, function(name, method) {
if (_.isFunction(method)) {
if (_.isUndefined(rewrite)) {
rewrite = true;
}
if (_.isUndefined(obj[name]) || rewrite) {
obj[name] = _.bind(method, context);
}
}
});
}
function loadYoutubePlayer() {
if (this.htmlPlayerLoaded) { return; }
console.log(
'[Video info]: Fetch metadata for YouTube video.'
);
this.fetchMetadata();
this.parseSpeed();
}
function loadHtmlPlayer() {
// When the youtube link doesn't work for any reason
// (for example, firewall) any
// alternate sources should automatically play.
if (!_prepareHTML5Video(this)) {
console.log(
'[Video info]: Continue loading '
+ 'YouTube video.'
);
// Non-YouTube sources were not found either.
this.el.find('.video-player div')
.removeClass('hidden');
this.el.find('.video-player .video-error')
.addClass('is-hidden');
// If in reality the timeout was to short, try to
// continue loading the YouTube video anyways.
this.loadYoutubePlayer();
} else {
console.log(
'[Video info]: Start HTML5 player.'
);
// In-browser HTML5 player does not support quality
// control.
this.el.find('.quality_control').hide();
_renderElements(this);
}
}
// function initialize(element)
// The function set initial configuration and preparation.
function initialize(element) {
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));

View File

@@ -1,135 +0,0 @@
/*
* 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));

View File

@@ -1,146 +0,0 @@
/* 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));

View File

@@ -1,390 +0,0 @@
/* 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));

View File

@@ -1,70 +0,0 @@
(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));

View File

@@ -1,92 +0,0 @@
(function(define) {
// eslint-disable-next-line lines-around-directive
'use strict';
// VideoSocialSharingHandler module.
define(
'video/036_video_social_sharing.js', ['underscore'],
function(_) {
var VideoSocialSharingHandler;
/**
* Video Social Sharing control module.
* @exports video/036_video_social_sharing.js
* @constructor
* @param {jquery Element} element
* @param {Object} options
*/
VideoSocialSharingHandler = function(element, options) {
if (!(this instanceof VideoSocialSharingHandler)) {
return new VideoSocialSharingHandler(element, options);
}
_.bindAll(this, 'clickHandler');
_.bindAll(this, 'copyHandler');
_.bindAll(this, 'hideHandler');
_.bindAll(this, 'showHandler');
this.container = element;
if (this.container.find('.wrapper-downloads .wrapper-social-share')) {
this.initialize();
}
return false;
};
VideoSocialSharingHandler.prototype = {
// Initializes the module.
initialize: function() {
this.el = this.container.find('.wrapper-social-share');
this.baseVideoUrl = this.el.data('url');
this.course_id = this.container.data('courseId');
this.block_id = this.container.data('blockId');
this.el.on('click', '.social-share-link', this.clickHandler);
this.closeBtn = this.el.find('.close-btn');
this.toggleBtn = this.el.find('.social-toggle-btn');
this.copyBtn = this.el.find('.public-video-copy-btn');
this.shareContainer = this.el.find('.container-social-share');
this.closeBtn.on('click', this.hideHandler);
this.toggleBtn.on('click', this.showHandler);
this.copyBtn.on('click', this.copyHandler);
},
// Fire an analytics event on share button click.
clickHandler: function(event) {
var self = this;
var source = $(event.currentTarget).data('source');
self.sendAnalyticsEvent(source);
},
hideHandler: function(event) {
this.shareContainer.hide();
this.toggleBtn.show();
},
showHandler: function(event) {
this.shareContainer.show();
this.toggleBtn.hide();
},
copyHandler: function(event) {
navigator.clipboard.writeText(this.copyBtn.data('url'));
},
// Send an analytics event for share button tracking.
sendAnalyticsEvent: function(source) {
window.analytics.track(
'edx.social.video.share_button.clicked',
{
source: source,
video_block_id: this.container.data('blockId'),
course_id: this.container.data('courseId'),
}
);
}
};
return VideoSocialSharingHandler;
});
}(RequireJS.define));

View File

@@ -1,247 +0,0 @@
(function(define) {
// VideoTranscriptFeedbackHandler module.
'use strict';
define('video/037_video_caption.js', ['underscore'],
function(_) {
/**
* @desc VideoTranscriptFeedbackHandler 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.
*
*/
var VideoTranscriptFeedbackHandler = function(state) {
if (!(this instanceof VideoTranscriptFeedbackHandler)) {
return new VideoTranscriptFeedbackHandler(state);
}
_.bindAll(this, 'destroy', 'getFeedbackForCurrentTranscript', 'markAsPositiveFeedback', 'markAsNegativeFeedback', 'markAsEmptyFeedback',
'selectThumbsUp', 'selectThumbsDown', 'unselectThumbsUp', 'unselectThumbsDown', 'thumbsUpClickHandler', 'thumbsDownClickHandler',
'sendFeedbackForCurrentTranscript', 'onHideLanguageMenu', 'getCurrentLanguage', 'loadAndSetVisibility', 'showWidget', 'hideWidget'
);
this.state = state;
this.state.videoTranscriptFeedback = this;
this.currentTranscriptLanguage = this.state.lang;
this.transcriptLanguages = this.state.config.transcriptLanguages;
if (this.state.el.find('.wrapper-transcript-feedback').length) {
this.initialize();
}
return false;
};
VideoTranscriptFeedbackHandler.prototype = {
destroy: function() {
this.state.el.off(this.events);
},
// Initializes the module.
initialize: function() {
this.el = this.state.el.find('.wrapper-transcript-feedback');
this.videoId = this.el.data('video-id');
this.userId = this.el.data('user-id');
this.aiTranslationsUrl = this.state.config.aiTranslationsUrl;
this.thumbsUpButton = this.el.find('.thumbs-up-btn');
this.thumbsDownButton = this.el.find('.thumbs-down-btn');
this.thumbsUpButton.on('click', this.thumbsUpClickHandler);
this.thumbsDownButton.on('click', this.thumbsDownClickHandler);
this.events = {
'language_menu:hide': this.onHideLanguageMenu,
destroy: this.destroy
};
this.loadAndSetVisibility();
this.bindHandlers();
},
bindHandlers: function() {
this.state.el.on(this.events);
},
getFeedbackForCurrentTranscript: function() {
var self = this;
var url = self.aiTranslationsUrl + '/transcript-feedback' + '?transcript_language=' + self.currentTranscriptLanguage + '&video_id=' + self.videoId + '&user_id=' + self.userId;
$.ajax({
url: url,
type: 'GET',
success: function(data) {
if (data && data.value === true) {
self.markAsPositiveFeedback();
self.currentFeedback = true;
} else {
if (data && data.value === false) {
self.markAsNegativeFeedback();
self.currentFeedback = false;
} else {
self.markAsEmptyFeedback();
self.currentFeedback = null;
}
}
},
error: function(error) {
self.markAsEmptyFeedback();
self.currentFeedback = null;
}
});
},
markAsPositiveFeedback: function() {
this.selectThumbsUp();
this.unselectThumbsDown();
},
markAsNegativeFeedback: function() {
this.selectThumbsDown();
this.unselectThumbsUp();
},
markAsEmptyFeedback: function() {
this.unselectThumbsUp();
this.unselectThumbsDown();
},
selectThumbsUp: function() {
var thumbsUpIcon = this.thumbsUpButton.find('.thumbs-up-icon');
if (thumbsUpIcon[0].classList.contains('fa-thumbs-o-up')) {
thumbsUpIcon[0].classList.remove("fa-thumbs-o-up");
thumbsUpIcon[0].classList.add("fa-thumbs-up");
}
},
selectThumbsDown: function() {
var thumbsDownIcon = this.thumbsDownButton.find('.thumbs-down-icon');
if (thumbsDownIcon[0].classList.contains('fa-thumbs-o-down')) {
thumbsDownIcon[0].classList.remove("fa-thumbs-o-down");
thumbsDownIcon[0].classList.add("fa-thumbs-down");
}
},
unselectThumbsUp: function() {
var thumbsUpIcon = this.thumbsUpButton.find('.thumbs-up-icon');
if (thumbsUpIcon[0].classList.contains('fa-thumbs-up')) {
thumbsUpIcon[0].classList.remove("fa-thumbs-up");
thumbsUpIcon[0].classList.add("fa-thumbs-o-up");
}
},
unselectThumbsDown: function() {
var thumbsDownIcon = this.thumbsDownButton.find('.thumbs-down-icon');
if (thumbsDownIcon[0].classList.contains('fa-thumbs-down')) {
thumbsDownIcon[0].classList.remove("fa-thumbs-down");
thumbsDownIcon[0].classList.add("fa-thumbs-o-down");
}
},
thumbsUpClickHandler: function() {
if (this.currentFeedback) {
this.sendFeedbackForCurrentTranscript(null);
} else {
this.sendFeedbackForCurrentTranscript(true);
}
},
thumbsDownClickHandler: function() {
if (this.currentFeedback === false) {
this.sendFeedbackForCurrentTranscript(null);
} else {
this.sendFeedbackForCurrentTranscript(false);
}
},
sendFeedbackForCurrentTranscript: function(feedbackValue) {
var self = this;
var url = self.aiTranslationsUrl + '/transcript-feedback/';
$.ajax({
url: url,
type: 'POST',
dataType: 'json',
data: {
transcript_language: self.currentTranscriptLanguage,
video_id: self.videoId,
user_id: self.userId,
value: feedbackValue,
},
success: function(data) {
if (data && data.value === true) {
self.markAsPositiveFeedback();
self.currentFeedback = true;
} else {
if (data && data.value === false) {
self.markAsNegativeFeedback();
self.currentFeedback = false;
} else {
self.markAsEmptyFeedback();
self.currentFeedback = null;
}
}
},
error: function() {
self.markAsEmptyFeedback();
self.currentFeedback = null;
}
});
},
onHideLanguageMenu: function() {
var newLanguageSelected = this.getCurrentLanguage();
if (this.currentTranscriptLanguage !== newLanguageSelected) {
this.currentTranscriptLanguage = this.getCurrentLanguage();
this.loadAndSetVisibility();
}
},
getCurrentLanguage: function() {
var language = this.state.lang;
return language;
},
loadAndSetVisibility: function() {
var self = this;
var url = self.aiTranslationsUrl + '/video-transcript' + '?transcript_language=' + self.currentTranscriptLanguage + '&video_id=' + self.videoId;
$.ajax({
url: url,
type: 'GET',
async: false,
success: function(data) {
if (data && data.status === 'Completed') {
self.showWidget();
self.getFeedbackForCurrentTranscript();
} else {
self.hideWidget();
}
},
error: function(error) {
self.hideWidget();
}
});
},
showWidget: function() {
this.el.show();
},
hideWidget: function() {
this.el.hide();
},
};
return VideoTranscriptFeedbackHandler;
});
}(RequireJS.define));

View File

@@ -1,914 +0,0 @@
/* 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);
}
// eslint-disable-next-line no-multi-assign
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() {
// eslint-disable-next-line no-shadow, no-multi-assign
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) {
// eslint-disable-next-line no-shadow
var update = function(state) {
// eslint-disable-next-line no-shadow
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) {
// eslint-disable-next-line no-shadow
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/openedx/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);
}
// eslint-disable-next-line no-shadow
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(' '));
// eslint-disable-next-line default-case
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]);
}
// eslint-disable-next-line no-shadow
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);
}
}
// eslint-disable-next-line no-shadow
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));

View File

@@ -1,169 +0,0 @@
(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,
startTime, currentTime;
// in case endTime is accidentally specified as being greater than the video
endTime = Math.min(endTime, params.duration);
startTime = this.config.startTime > 0 ? this.config.startTime : 0;
// if it's a subsection of video, use the clip duration as endTime
if (startTime && this.config.endTime) {
endTime = this.config.endTime - startTime;
}
currentTime = startTime ? params.time - startTime : params.time;
this.videoControl.vidTimeEl.text(Time.format(currentTime) + ' / ' + Time.format(endTime));
}
}
);
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));

View File

@@ -1,313 +0,0 @@
(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;
}
function notifyParent(fullscreenOpen) {
if (window !== window.parent) {
// This is used by the Learning MFE to know about changing fullscreen mode.
// The MFE is then able to respond appropriately and scroll window to the previous position.
window.parent.postMessage({
type: 'plugin.videoFullScreen',
payload: {
open: fullscreenOpen
}
}, document.referrer
);
}
}
/**
* 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;
}
// eslint-disable-next-line no-multi-assign
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]);
this.videoFullScreen.notifyParent(false);
}
function handleEnter() {
var fullScreenClassNameEl = this.el.add(document.documentElement);
var closedCaptionsEl = this.el.find('.closed-captions');
if (this.isFullScreen === true) {
return;
}
this.videoFullScreen.notifyParent(true);
// eslint-disable-next-line no-multi-assign
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,
notifyParent: notifyParent
};
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));

View File

@@ -1,181 +0,0 @@
(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>&nbsp;',
'<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) {
// eslint-disable-next-line no-multi-assign
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));

View File

@@ -1,367 +0,0 @@
(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;
// eslint-disable-next-line default-case
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 %= 60;
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));

View File

@@ -1,554 +0,0 @@
(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();
// eslint-disable-next-line no-multi-assign
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;
// eslint-disable-next-line default-case
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;
// eslint-disable-next-line default-case
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));

View File

@@ -1,422 +0,0 @@
(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');
// eslint-disable-next-line no-extra-boolean-cast
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;
// eslint-disable-next-line default-case
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');
// eslint-disable-next-line default-case
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));

View File

@@ -1,687 +0,0 @@
(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');
// eslint-disable-next-line default-case
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;
// eslint-disable-next-line default-case
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;
// eslint-disable-next-line default-case
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();
// eslint-disable-next-line default-case
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;
// eslint-disable-next-line default-case
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) {
// eslint-disable-next-line no-shadow
var item = menuitem.getChildren().filter(function(item) {
return item.options.speed === speed;
})[0];
if (item) {
item.select();
}
}
});
}
}
]
};
// eslint-disable-next-line no-shadow
$.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));

View File

@@ -1,112 +0,0 @@
(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));

View File

@@ -1,202 +0,0 @@
(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));

View File

@@ -1,113 +0,0 @@
(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));

View File

@@ -1,179 +0,0 @@
(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,
startTime = this.state.config.startTime,
currentTime;
currentTime = player ? player.currentTime : 0;
// if video didn't start from 0(it's a subsection of video), subtract the additional time at start
if (startTime) {
currentTime = currentTime ? currentTime - startTime : 0;
}
return currentTime;
},
getCurrentLanguage: function() {
var language = this.state.lang;
return language;
},
log: function(eventName, data) {
// use startTime and endTime to calculate the duration to handle the case where only a subsection of video is used
var endTime = this.state.config.endTime || this.state.duration,
startTime = this.state.config.startTime;
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: endTime - startTime
}, data, this.options.data);
Logger.log(eventName, logInfo);
}
};
return EventsPlugin;
});
}(RequireJS.define));

View File

@@ -1,97 +0,0 @@
(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));

View File

@@ -1,88 +0,0 @@
(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));

View File

@@ -1,90 +0,0 @@
(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));

View File

@@ -1,70 +0,0 @@
(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));

View File

@@ -1,131 +0,0 @@
(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));

View File

@@ -1,76 +0,0 @@
(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));

View File

@@ -1,1463 +0,0 @@
(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',
'updateGoogleDisclaimer', 'toggleGoogleDisclaimer', 'updateProblematicCaptionsContent'
);
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();
// eslint-disable-next-line prefer-spread
var results = this.sjson.filter.apply(this.sjson, times);
var start = results.start;
var captions = results.captions;
return {
start: start,
captions: captions
};
},
/**
* @desc Sets whether or not the Google disclaimer should be shown based on captions
* being AI generated, and shows/hides based on the above and if ClosedCaptions are being shown.
*
* @param {array} captions List of captions for the video.
*
* @returns {boolean}
*/
updateGoogleDisclaimer: function(captions) {
const aIGeneratedSpanText = '<span id="captions-ai-generated"',
aiProviderRegexp = /data\-provider=["'](?<provider>\w+)["']/;
var self = this,
state = this.state,
aiGeneratedSpan = captions.find(caption => caption.includes(aIGeneratedSpanText)),
captionsAIGenerated = !(aiGeneratedSpan === undefined),
aiCaptionProviderIsGoogle = true;
if (captionsAIGenerated) {
const providerMatch = aiProviderRegexp.exec(aiGeneratedSpan);
if (providerMatch !== null) {
aiCaptionProviderIsGoogle = providerMatch.groups['provider'] === 'gcp';
}
// If there is no provider tag, it was generated before we added those,
// so it must be Google
}
// This field is whether or not, in general, this video should show the google disclaimer
self.shouldShowGoogleDisclaimer = captionsAIGenerated && aiCaptionProviderIsGoogle;
// Should we, right now, on load, show the google disclaimer
self.toggleGoogleDisclaimer(!self.hideCaptionsOnLoad && !state.captionsHidden);
},
/**
* @desc Show or hide the google translate disclaimer based on the passed param
* and whether or not we are currently showing a google translated transcript.
* @param {boolean} [show] Show if true, hide if false - if we are showing a google
* translated transcript. If not, this will always hide.
*/
toggleGoogleDisclaimer: function(show) {
var self = this,
state = this.state;
if (show && self.shouldShowGoogleDisclaimer) {
state.el.find('.google-disclaimer').show();
} else {
state.el.find('.google-disclaimer').hide();
}
},
/**
* @desc Replaces content in a caption
*
* @param {array} captions List of captions for the video.
* @param {string} content content to be replaced
* @param {string} replacementContent the replace string
*
* @returns {array} captions List of captions for the video.
*/
updateProblematicCaptionsContent: function(captions, content = '', replacementContent = '') {
var updatedCaptions = captions.map(caption => caption.replace(content, replacementContent));
return updatedCaptions;
},
/**
* @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;
var contentToReplace = CAPTIONS_CONTENT_TO_REPLACE,
replacementContent = CAPTIONS_CONTENT_REPLACEMENT;
captions = self.updateProblematicCaptionsContent(captions, contentToReplace, replacementContent);
self.updateGoogleDisclaimer(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);
// eslint-disable-next-line prefer-spread
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')
.attr('aria-current', 'false')
.removeClass('current');
}
this.subtitlesEl
.find("span[data-index='" + newIndex + "']")
.parent()
.attr('aria-current', 'true')
.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();
this.toggleGoogleDisclaimer(false);
} else {
this.state.showClosedCaptions = true;
this.updateCaptioningCookie(true);
this.showClosedCaptions();
this.toggleGoogleDisclaimer(true);
}
},
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') {
// eslint-disable-next-line no-new
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,
self = this,
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');
}
self.toggleGoogleDisclaimer(false);
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');
}
self.toggleGoogleDisclaimer(true);
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));

View File

@@ -1,111 +0,0 @@
(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));

View File

@@ -1,198 +0,0 @@
/* 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',
'video/036_video_social_sharing.js',
'video/037_video_transcript_feedback.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, VideoSocialSharing, VideoTranscriptFeedback
) {
/* 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, VideoTranscriptFeedback
].concat(autoAdvanceEnabled ? [VideoAutoAdvanceControl] : []),
bumperVideoModules = [VideoControl, VideoPlaySkipControl, VideoSkipControl,
VideoVolumeControl, VideoCaption, VideoCommands, VideoSaveStatePlugin, VideoTranscriptFeedback,
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
});
VideoSocialSharing(el);
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 */

View File

@@ -247,7 +247,7 @@ class _BuiltInVideoBlock(
fragment = Fragment(self.get_html(context=context))
add_css_to_fragment(fragment, 'VideoBlockDisplay.css')
add_webpack_js_to_fragment(fragment, 'VideoBlockDisplay')
shim_xmodule_js(fragment, 'Video')
fragment.initialize_js('Video')
return fragment
def author_view(self, context):
@@ -280,8 +280,8 @@ class _BuiltInVideoBlock(
fragment = Fragment(self.get_html(view=PUBLIC_VIEW, context=context))
add_css_to_fragment(fragment, 'VideoBlockDisplay.css')
add_webpack_js_to_fragment(fragment, 'VideoBlockDisplay')
shim_xmodule_js(fragment, 'Video')
add_webpack_js_to_fragment(fragment, 'VideoBlockMain')
fragment.initialize_js('Video')
return fragment
def get_html(self, view=STUDENT_VIEW, context=None): # lint-amnesty, pylint: disable=arguments-differ, too-many-statements