Commit d8144b0d by Valera Rozuvan

Merge pull request #2365 from edx/anton/store-video-progress

Anton/store video progress
parents 0b3604f8 102d1cb2
......@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Blades: Persist student progress in video. BLD-385.
Blades: Fix for the list metadata editor that gets into a bad state where "Add"
is disabled. BLD-821.
......
......@@ -95,3 +95,54 @@ Feature: CMS.Video Component
And I save changes
And I click video button "play"
Then I see a range on slider
# 12
Scenario: Check that position is stored on page refresh, position within start-end range
Given I have created a Video component with subtitles
And Make sure captions are closed
And I edit the component
And I open tab "Advanced"
And I set value "00:00:12" to the field "Start Time"
And I set value "00:00:24" to the field "End Time"
And I save changes
And I click video button "play"
Then I see a range on slider
Then I seek video to "16" seconds
And I click video button "pause"
And I reload the page
And I click video button "play"
Then I see video starts playing from "0:16" position
# 13
Scenario: Check that position is stored on page refresh, position before start-end range
Given I have created a Video component with subtitles
And Make sure captions are closed
And I edit the component
And I open tab "Advanced"
And I set value "00:00:12" to the field "Start Time"
And I set value "00:00:24" to the field "End Time"
And I save changes
And I click video button "play"
Then I see a range on slider
Then I seek video to "5" seconds
And I click video button "pause"
And I reload the page
And I click video button "play"
Then I see video starts playing from "0:12" position
# 14
Scenario: Check that position is stored on page refresh, position after start-end range
Given I have created a Video component with subtitles
And Make sure captions are closed
And I edit the component
And I open tab "Advanced"
And I set value "00:00:12" to the field "Start Time"
And I set value "00:00:24" to the field "End Time"
And I save changes
And I click video button "play"
Then I see a range on slider
Then I seek video to "30" seconds
And I click video button "pause"
And I reload the page
And I click video button "play"
Then I see video starts playing from "0:12" position
......@@ -198,3 +198,17 @@ def click_button_video(_step, button_type):
button = button_type.strip()
world.css_click(VIDEO_BUTTONS[button])
@step('I seek video to "([^"]*)" seconds$')
def seek_video_to_n_seconds(_step, seconds):
time = float(seconds.strip())
jsCode = "$('.video').data('video-player-state').videoPlayer.onSlideSeek({{time: {0:f}}})".format(time)
world.browser.execute_script(jsCode)
@step('I see video starts playing from "([^"]*)" position$')
def start_playing_video_from_n_seconds(_step, position):
world.wait_for(
func=lambda _: world.css_html('.vidtime')[:4] == position.strip(),
timeout=5
)
......@@ -140,7 +140,8 @@ class RelativeTime(Field):
# Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types
MUTABLE = False
def _isotime_to_timedelta(self, value):
@classmethod
def isotime_to_timedelta(cls, value):
"""
Validate that value in "HH:MM:SS" format and convert to timedelta.
......@@ -175,7 +176,7 @@ class RelativeTime(Field):
return datetime.timedelta(seconds=value)
if isinstance(value, basestring):
return self._isotime_to_timedelta(value)
return self.isotime_to_timedelta(value)
msg = "RelativeTime Field {0} has bad value '{1!r}'".format(self._name, value)
raise TypeError(msg)
......
......@@ -10,6 +10,7 @@
data-speed="1.5"
data-start=""
data-end=""
data-saved-video-position="0"
data-caption-asset-path="/static/subs/"
data-autoplay="False"
data-yt-test-timeout="1500"
......
......@@ -9,6 +9,7 @@
data-speed="1.5"
data-start=""
data-end=""
data-saved-video-position="0"
data-caption-asset-path="/static/subs/"
data-sub="Z5KLxerq05Y"
data-mp4-source="xmodule/include/fixtures/test.mp4"
......
......@@ -9,6 +9,7 @@
data-speed="1.5"
data-start=""
data-end=""
data-saved-video-position="0"
data-caption-asset-path="/static/subs/"
data-sub="Z5KLxerq05Y"
data-mp4-source="xmodule/include/fixtures/test.mp4"
......
......@@ -10,6 +10,7 @@
data-speed="1.5"
data-start=""
data-end=""
data-saved-video-position="0"
data-caption-asset-path="/static/subs/"
data-autoplay="False"
data-yt-test-timeout="1500"
......
......@@ -10,6 +10,7 @@
data-speed="1.5"
data-start=""
data-end=""
data-saved-video-position="0"
data-caption-asset-path="/static/subs/"
data-autoplay="False"
data-yt-test-timeout="1500"
......
......@@ -207,18 +207,17 @@
beforeEach(function () {
this.addMatchers({
toHaveAttrs: function (attrs) {
var element = this.actual,
result = true;
var element;
if ($.isEmptyObject(attrs)) {
return false;
}
$.each(attrs, function (name, value) {
return result = result && element.attr(name) === value;
});
element = this.actual;
return result;
return _.every(attrs, function (value, name) {
return element.attr(name) === value;
});
},
toBeInRange: function (min, max) {
return min <= this.actual && this.actual <= max;
......
(function (requirejs, require, define) {
require(
['video/00_cookie_storage.js'],
function (CookieStorage) {
describe('CookieStorage', function () {
var mostRecentCall;
beforeEach(function () {
mostRecentCall = $.cookie.mostRecentCall;
});
afterEach(function () {
CookieStorage('test_storage').clear();
});
describe('intialize', function () {
it('with namespace', function () {
var storage = CookieStorage('test_storage');
storage.setItem('item_1', 'value_1');
expect(mostRecentCall.args[0]).toBe('test_storage');
});
it('without namespace', function () {
var storage = CookieStorage();
storage.setItem('item_1', 'value_1');
expect(mostRecentCall.args[0]).toBe('cookieStorage');
});
});
it('unload', function () {
var expected = JSON.stringify({
storage: {
item_2: {
value: 'value_2',
session: false
}
},
keys: ['item_2']
}),
storage = CookieStorage('test_storage');
storage.setItem('item_1', 'value_1', true);
storage.setItem('item_2', 'value_2');
$(window).trigger('unload');
expect(mostRecentCall.args[1]).toBe(expected);
});
describe('methods: ', function () {
var data = {
storage: {
item_1: {
value: 'value_1',
session: false
}
},
keys: ['item_1']
},
storage;
beforeEach(function () {
$.cookie.andReturn(JSON.stringify(data));
storage = CookieStorage('test_storage');
});
describe('setItem', function () {
it('pass correct data', function () {
var expected = JSON.stringify({
storage: {
item_1: {
value: 'value_1',
session: false
},
item_2: {
value: 'value_2',
session: false
},
item_3: {
value: 'value_3',
session: true
},
},
keys: ['item_1', 'item_2', 'item_3']
});
storage.setItem('item_2', 'value_2');
storage.setItem('item_3', 'value_3', true);
expect(mostRecentCall.args[0]).toBe('test_storage');
expect(mostRecentCall.args[1]).toBe(expected);
});
it('pass broken arguments', function () {
$.cookie.reset();
storage.setItem(null, 'value_1');
expect($.cookie).not.toHaveBeenCalled();
});
});
describe('getItem', function () {
it('item exist', function () {
$.each(data['storage'], function(key, value) {
expect(storage.getItem(key)).toBe(value['value']);
});
});
it('item does not exist', function () {
expect(storage.getItem('nonexistent')).toBe(null);
});
});
describe('removeItem', function () {
it('item exist', function () {
var expected = JSON.stringify({
storage: {},
keys: []
});
storage.removeItem('item_1');
expect(mostRecentCall.args[1]).toBe(expected);
});
it('item does not exist', function () {
storage.removeItem('nonexistent');
expect(mostRecentCall.args[1]).toBe(JSON.stringify(data));
});
});
it('clear', function () {
storage.clear();
expect(mostRecentCall.args[1]).toBe(null);
});
describe('key', function () {
it('key exist', function () {
$.each(data['keys'], function(index, name) {
expect(storage.key(index)).toBe(name);
});
});
it('key is grater than keys list', function () {
expect(storage.key(100)).toBe(null);
});
});
});
});
});
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
......@@ -19,6 +19,7 @@
afterEach(function () {
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
});
it('initialize', function () {
......
......@@ -8,6 +8,8 @@
afterEach(function () {
$('source').remove();
window.VideoState = {};
window.VideoState.id = {};
});
describe('constructor', function () {
......@@ -80,7 +82,7 @@
'0.75': sub,
'1.0': sub,
'1.25': sub,
'1.5': sub
'1.50': sub
});
});
......@@ -97,7 +99,7 @@
'0.75': sub,
'1.0': sub,
'1.25': sub,
'1.5': sub
'1.50': sub
});
});
......@@ -227,10 +229,17 @@
expect(state.videoPlayer.skipOnEndedStartEndReset).toBe(true);
});
it('when position is not 0: cue is called with stored position value', function () {
state.config.savedVideoPosition = 15;
state.videoPlayer.updatePlayTime(10);
expect(state.videoPlayer.player.cueVideoById).toHaveBeenCalledWith('cogebirgzzM', 15);
});
it('Handling cue state', function () {
spyOn(state.videoPlayer, 'play');
state.videoPlayer.startTime = 10;
state.videoPlayer.seekToTimeOnCued = 10;
state.videoPlayer.onStateChange({data: 5});
expect(state.videoPlayer.player.seekTo).toHaveBeenCalledWith(10, true);
......@@ -397,7 +406,7 @@
it('save setting for new speed', function () {
expect(state.storage.getItem('general_speed')).toBe('0.75');
expect(state.storage.getItem('video_speed_' + state.id)).toBe('0.75');
expect(state.storage.getItem('speed', true)).toBe('0.75');
});
});
......
......@@ -9,7 +9,7 @@
});
afterEach(function () {
state = undefined;
state.storage.clear();
$.fn.scrollTo.reset();
$('.subtitles').remove();
$('source').remove();
......
(function (requirejs, require, define, undefined) {
'use strict';
require(
['video/01_initialize.js'],
function (Initialize) {
describe('Initialize', function () {
describe('saveState function', function () {
var state, videoPlayerCurrentTime, newCurrentTime, speed;
// We make sure that `currentTime` is a float. We need to test
// that Math.round() is called.
videoPlayerCurrentTime = 3.1242;
// We have two times, because one is stored in
// `videoPlayer.currentTime`, and the other is passed directly to
// `saveState` in `data` object. In each case, there is different
// code that handles these times. They have to be different for
// test completeness sake. Also, make sure it is float, as is the
// time above.
newCurrentTime = 5.4;
speed = '0.75';
beforeEach(function () {
state = {
videoPlayer: {
currentTime: videoPlayerCurrentTime
},
storage: {
setItem: jasmine.createSpy()
},
config: {
saveStateUrl: 'http://example.com/save_user_state'
}
};
spyOn($, 'ajax');
spyOn(Time, 'formatFull').andCallThrough();
});
afterEach(function () {
state = undefined;
});
it('data is not an object, async is true', function () {
itSpec({
asyncVal: true,
speedVal: undefined,
positionVal: videoPlayerCurrentTime,
data: undefined,
ajaxData: {
saved_video_position: Time.formatFull(Math.round(videoPlayerCurrentTime))
}
});
});
it('data contains speed, async is false', function () {
itSpec({
asyncVal: false,
speedVal: speed,
positionVal: undefined,
data: {
speed: speed
},
ajaxData: {
speed: speed
}
});
});
it('data contains float position, async is true', function () {
itSpec({
asyncVal: true,
speedVal: undefined,
positionVal: newCurrentTime,
data: {
saved_video_position: newCurrentTime
},
ajaxData: {
saved_video_position: Time.formatFull(Math.round(newCurrentTime))
}
});
});
it('data contains speed and rounded position, async is false', function () {
itSpec({
asyncVal: false,
speedVal: speed,
positionVal: Math.round(newCurrentTime),
data: {
speed: speed,
saved_video_position: Math.round(newCurrentTime)
},
ajaxData: {
speed: speed,
saved_video_position: Time.formatFull(Math.round(newCurrentTime))
}
});
});
it('data contains empty object, async is true', function () {
itSpec({
asyncVal: true,
speedVal: undefined,
positionVal: undefined,
data: {},
ajaxData: {}
});
});
return;
function itSpec(value) {
var asyncVal = value.asyncVal,
speedVal = value.speedVal,
positionVal = value.positionVal,
data = value.data,
ajaxData = value.ajaxData;
Initialize.prototype.saveState.call(state, asyncVal, data);
if (speedVal) {
expect(state.storage.setItem).toHaveBeenCalledWith(
'speed',
speedVal,
true
);
}
if (positionVal) {
expect(state.storage.setItem).toHaveBeenCalledWith(
'savedVideoPosition',
positionVal,
true
);
expect(Time.formatFull).toHaveBeenCalledWith(
positionVal
);
}
expect($.ajax).toHaveBeenCalledWith({
url: state.config.saveStateUrl,
type: 'POST',
async: asyncVal,
dataType: 'json',
data: ajaxData
});
}
});
});
});
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
......@@ -8,10 +8,7 @@
.andReturn(null);
state = jasmine.initializePlayer();
videoControl = state.videoControl;
$.fn.scrollTo.reset();
});
afterEach(function () {
......@@ -21,6 +18,8 @@
// had before. Removing of `source` tag, not `video` tag, stops
// loading video source and clears the memory.
$('source').remove();
$.fn.scrollTo.reset();
state.storage.clear();
window.onTouchBasedDevice = oldOTBD;
});
......
......@@ -10,6 +10,7 @@
afterEach(function () {
$('source').remove();
state.storage.clear();
window.onTouchBasedDevice = oldOTBD;
});
......
......@@ -11,6 +11,7 @@
afterEach(function () {
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
});
describe('constructor', function () {
......@@ -213,10 +214,6 @@
);
});
it('pause other video player', function () {
expect(oldState.videoPlayer.onPause).toHaveBeenCalled();
});
it('set update interval', function () {
expect(window.setInterval).toHaveBeenCalledWith(
state.videoPlayer.update, 200
......
......@@ -11,6 +11,7 @@
afterEach(function () {
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
});
describe('constructor', function () {
......
......@@ -12,6 +12,7 @@
afterEach(function () {
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
});
describe('constructor', function () {
......@@ -20,9 +21,15 @@
});
it('render the quality control', function () {
var container = state.videoControl.secondaryControlsEl;
waitsFor(function () {
return state.videoControl;
}, 'videoControl is present', 5000);
expect(container).toContain('a.quality_control');
runs(function () {
var container = state.videoControl.secondaryControlsEl;
expect(container).toContain('a.quality_control');
});
});
it('add ARIA attributes to quality control', function () {
......
......@@ -11,6 +11,7 @@
afterEach(function () {
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
});
describe('constructor', function () {
......
(function (requirejs, require, define, undefined) {
require(
['video/00_video_storage.js'],
function (VideoStorage) {
describe('VideoStorage', function () {
var namespace = 'test_storage',
id = 'video_id';
afterEach(function () {
VideoStorage(namespace, id).clear();
});
describe('initialize', function () {
it('with namespace and id', function () {
var storage = VideoStorage(namespace, id);
expect(window[namespace]).toBeDefined();
expect(window[namespace][id]).toBeDefined();
});
it('without namespace and id', function () {
spyOn(Number.prototype, 'toString').andReturn('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 () {
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 () {
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);
});
});
});
});
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
......@@ -11,6 +11,7 @@
afterEach(function () {
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
});
describe('constructor', function () {
......
......@@ -13,5 +13,18 @@ class @Time
else
"#{minutes}:#{pad(seconds % 60)}"
@formatFull: (time) ->
pad = (number) -> if number < 10 then "0#{number}" else number
seconds = Math.floor time
minutes = Math.floor seconds / 60
hours = Math.floor minutes / 60
seconds = seconds % 60
minutes = minutes % 60
# The returned value will not be user-facing. So no need for
# internationalization.
"#{pad(hours)}:#{pad(minutes)}:#{pad(seconds % 60)}"
@convert: (time, oldSpeed, newSpeed) ->
(time * oldSpeed / newSpeed).toFixed(3)
(function (requirejs, require, define) {
define(
'video/00_cookie_storage.js',
[],
function() {
"use strict";
/**
* Provides convenient way to work with cookies.
*
* Maximum 4096 bytes can be stored per namespace.
*
* @TODO: Uses localStorage if available.
*
* @param {string} namespace Namespace that is used to store data.
* @return {object} CookieStorage API.
*/
var CookieStorage = function (namespace) {
var Storage;
/**
* Returns an empty storage with proper data structure.
*
* @private
* @return {object} Empty storage.
*/
var _getEmptyStorage = function () {
return {
storage: {},
keys: []
};
};
/**
* Returns the current value associated with the given namespace.
* If data doesn't exist or has data with incorrect interface, it creates
* an empty storage with proper data structure.
*
* @private
* @param {string} namespace Namespace that is used to store data.
* @return {object} Stored data or an empty storage.
*/
var _getData = function (namespace) {
var data;
try {
data = JSON.parse($.cookie(namespace));
} catch (err) { }
if (!data || !data['storage'] || !data['keys']) {
return _getEmptyStorage();
}
return data;
};
/**
* Clears cookies that has flag `session` equals true.
*
* @private
*/
var _clearSession = function () {
Storage['keys'] = $.grep(Storage['keys'], function(key_name, index) {
if (Storage['storage'][key_name]['session']) {
delete Storage['storage'][key_name];
return false;
}
return true;
});
$.cookie(namespace, JSON.stringify(Storage), {
expires: 3650,
path: '/'
});
};
/**
* Adds new value to the storage or rewrites existent.
*
* @param {string} name Identifier of the data.
* @param {any} value Data to store.
* @param {boolean} useSession Data with this flag will be removed on
* window unload.
*/
var setItem = function (name, value, useSession) {
if (name) {
if ($.inArray(name, Storage['keys']) === -1) {
Storage['keys'].push(name);
}
Storage['storage'][name] = {
value: value,
session: useSession ? true : false
};
$.cookie(namespace, JSON.stringify(Storage), {
expires: 3650,
path: '/'
});
}
};
/**
* Returns the current value associated with the given name.
*
* @param {string} name Identifier of the data.
* @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) {
try {
return Storage['storage'][name]['value'];
} catch (err) { }
return null;
};
/**
* Removes the current value associated with the given name.
*
* @param {string} name Identifier of the data.
*/
var removeItem = function (name) {
delete Storage['storage'][name];
Storage['keys'] = $.grep(Storage['keys'], function(key_name, index) {
return name !== key_name;
});
$.cookie(namespace, JSON.stringify(Storage), {
expires: 3650,
path: '/'
});
};
/**
* Empties the storage.
*
*/
var clear = function () {
Storage = _getEmptyStorage();
$.cookie(namespace, null, {
expires: -1,
path: '/'
});
};
/**
* Returns the name of the `n`th key in the list.
*
* @param {number} n Index of the key.
* @return {string} Name of the `n`th key in the list.
* If `n` is greater than or equal to the number of key/value pairs
* in the object, then this method must return `null`.
*/
var key = function (n) {
if (n >= Storage['keys'].length) {
return null;
}
return Storage['keys'][n];
};
/**
* Initializes the module: creates a storage with proper namespace, binds
* `unload` event.
*
* @private
*/
(function initialize() {
if (!namespace) {
namespace = 'cookieStorage';
}
Storage = _getData(namespace);
$(window).unload(_clearSession);
}());
return {
clear: clear,
getItem: getItem,
key: key,
removeItem: removeItem,
setItem: setItem
};
};
return CookieStorage;
});
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
(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));
......@@ -14,8 +14,8 @@
define(
'video/01_initialize.js',
['video/03_video_player.js', 'video/00_cookie_storage.js'],
function (VideoPlayer, CookieStorage) {
['video/03_video_player.js', 'video/00_video_storage.js'],
function (VideoPlayer, VideoStorage) {
/**
* @function
*
......@@ -26,7 +26,7 @@ function (VideoPlayer, CookieStorage) {
* available via this object.
* @param {DOM element} element Container of the entire Video DOM element.
*/
return function (state, element) {
var Initialize = function (state, element) {
_makeFunctionsPublic(state);
state.initialize(element)
......@@ -56,8 +56,26 @@ function (VideoPlayer, CookieStorage) {
state.el.trigger('initialize', arguments);
});
});
},
methodsDict = {
bindTo: bindTo,
fetchMetadata: fetchMetadata,
getDuration: getDuration,
getVideoMetadata: getVideoMetadata,
initialize: initialize,
parseSpeed: parseSpeed,
parseVideoSources: parseVideoSources,
parseYoutubeStreams: parseYoutubeStreams,
saveState: saveState,
setSpeed: setSpeed,
trigger: trigger,
youtubeId: youtubeId
};
Initialize.prototype = methodsDict;
return Initialize;
// ***************************************************************
// Private functions start here. Private functions start with underscore.
// ***************************************************************
......@@ -73,21 +91,6 @@ function (VideoPlayer, CookieStorage) {
* methods, modules) of the Video player.
*/
function _makeFunctionsPublic(state) {
var methodsDict = {
bindTo: bindTo,
fetchMetadata: fetchMetadata,
getDuration: getDuration,
getVideoMetadata: getVideoMetadata,
initialize: initialize,
parseSpeed: parseSpeed,
parseVideoSources: parseVideoSources,
parseYoutubeStreams: parseYoutubeStreams,
setSpeed: setSpeed,
stopBuffering: stopBuffering,
trigger: trigger,
youtubeId: youtubeId
};
bindTo(methodsDict, state, state);
}
......@@ -201,7 +204,7 @@ function (VideoPlayer, CookieStorage) {
'0.75': state.config.sub,
'1.0': state.config.sub,
'1.25': state.config.sub,
'1.5': state.config.sub
'1.50': state.config.sub
};
// We must have at least one non-YouTube video source available.
......@@ -271,13 +274,13 @@ function (VideoPlayer, CookieStorage) {
return dfd.promise();
}
function _getConfiguration(data) {
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 = ['speed'],
extractKeys = [],
// Compatibility keys used to change names of some parameters in
// the final configuration.
compatKeys = {
......@@ -289,6 +292,19 @@ function (VideoPlayer, CookieStorage) {
'showCaptions': isBoolean,
'autoplay': isBoolean,
'autohideHtml5': isBoolean,
'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';
},
'ytTestTimeout': function (value) {
value = parseInt(value, 10);
......@@ -380,12 +396,7 @@ function (VideoPlayer, CookieStorage) {
id = el.attr('id').replace(/video_/, ''),
__dfd__ = $.Deferred(),
isTouch = onTouchBasedDevice() || '',
storage = CookieStorage('video_player'),
speed = storage.getItem('video_speed_' + id) ||
el.data('speed') ||
storage.getItem('general_speed') ||
el.data('general-speed') ||
'1.0';
storage = VideoStorage('VideoState', id);
if (isTouch) {
el.addClass('is-touch');
......@@ -399,7 +410,6 @@ function (VideoPlayer, CookieStorage) {
id: id,
isFullScreen: false,
isTouch: isTouch,
speed: Number(speed).toFixed(2).replace(/\.00$/, '.0'),
storage: storage
});
......@@ -411,7 +421,7 @@ function (VideoPlayer, CookieStorage) {
// 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(el.data()), {
this.config = $.extend({}, _getConfiguration(el.data(), storage), {
element: element,
fadeOutTimeout: 1400,
captionsFreezeTime: 10000,
......@@ -422,6 +432,10 @@ function (VideoPlayer, CookieStorage) {
this.config.endTime = null;
}
this.speed = Number(
this.config.speed || this.config.generalSpeed
).toFixed(2).replace(/\.00$/, '.0');
if (!(_parseYouTubeIDs(this))) {
// If we do not have YouTube ID's, try parsing HTML5 video sources.
......@@ -637,8 +651,8 @@ function (VideoPlayer, CookieStorage) {
}
if (updateStorage) {
this.storage.setItem('video_speed_' + this.id, this.speed, useSession);
this.storage.setItem('general_speed', this.speed, useSession);
this.storage.setItem('speed', this.speed, true);
this.storage.setItem('general_speed', this.speed);
}
}
......@@ -659,20 +673,34 @@ function (VideoPlayer, CookieStorage) {
return xhr;
}
function stopBuffering() {
var video;
function saveState(async, data) {
if (!($.isPlainObject(data))) {
data = {
saved_video_position: this.videoPlayer.currentTime
};
}
if (this.videoType === 'html5') {
// HTML5 player haven't default way to abort bufferization.
// In this case we simply resetting source and call load().
video = this.videoPlayer.player.video;
video.src = '';
video.load();
if (data.speed) {
this.storage.setItem('speed', data.speed, true);
}
if (data.saved_video_position) {
this.storage.setItem('savedVideoPosition', data.saved_video_position, true);
data.saved_video_position = Time.formatFull(data.saved_video_position);
}
$.ajax({
url: this.config.saveStateUrl,
type: 'POST',
async: async ? true : false,
dataType: 'json',
data: data,
});
}
function youtubeId(speed) {
return this.videos[speed || this.speed];
return this.videos[speed || this.speed] || this.videos['1.0'];
}
function getDuration() {
......
......@@ -67,6 +67,8 @@ function (HTML5Video, Resizer) {
// metadata is loaded, which normally happens just after the video
// starts playing. Just after that configurations can be applied.
state.videoPlayer.ready = _.once(function () {
$(window).on('unload', state.saveState);
if (state.currentPlayerMode !== 'flash') {
state.videoPlayer.onSpeedChange(state.speed);
}
......@@ -292,8 +294,7 @@ function (HTML5Video, Resizer) {
// (currentTime) and its duration.
// It is called at a regular interval when the video is playing.
function update() {
this.videoPlayer.currentTime = this.videoPlayer.player
.getCurrentTime();
this.videoPlayer.currentTime = this.videoPlayer.player.getCurrentTime();
if (isFinite(this.videoPlayer.currentTime)) {
this.videoPlayer.updatePlayTime(this.videoPlayer.currentTime);
......@@ -379,14 +380,7 @@ function (HTML5Video, Resizer) {
this.el.trigger('speedchange', arguments);
$.ajax({
url: this.config.saveStateUrl,
type: 'POST',
dataType: 'json',
data: {
speed: newSpeed
},
});
this.saveState(true, { speed: newSpeed });
}
// Every 200 ms, if the video is playing, we call the function update, via
......@@ -485,6 +479,7 @@ function (HTML5Video, Resizer) {
this.trigger('videoCaption.pause', null);
}
this.saveState(true);
this.el.trigger('pause', arguments);
}
......@@ -640,7 +635,7 @@ function (HTML5Video, Resizer) {
this.videoPlayer.onEnded();
break;
case this.videoPlayer.PlayerState.CUED:
this.videoPlayer.player.seekTo(this.videoPlayer.startTime, true);
this.videoPlayer.player.seekTo(this.videoPlayer.seekToTimeOnCued, true);
// We need to call play() explicitly because after the call
// to functions cueVideoById() followed by seekTo() the video
// is in a PAUSED state.
......@@ -652,28 +647,26 @@ function (HTML5Video, Resizer) {
}
function updatePlayTime(time) {
var duration = this.videoPlayer.duration(),
durationChange, tempStartTime, tempEndTime, youTubeId;
var videoPlayer = this.videoPlayer,
duration = this.videoPlayer.duration(),
savedVideoPosition = this.config.savedVideoPosition,
isNewSpeed = videoPlayer.seekToStartTimeOldSpeed !== this.speed,
durationChange, tempStartTime, tempEndTime, youTubeId,
startTime, endTime;
if (
duration > 0 &&
(
this.videoPlayer.seekToStartTimeOldSpeed !== this.speed ||
this.videoPlayer.initialSeekToStartTime
)
(isNewSpeed || videoPlayer.initialSeekToStartTime)
) {
if (
this.videoPlayer.seekToStartTimeOldSpeed !== this.speed &&
this.videoPlayer.initialSeekToStartTime === false
) {
if (isNewSpeed && videoPlayer.initialSeekToStartTime === false) {
durationChange = true;
} else { // this.videoPlayer.initialSeekToStartTime === true
this.videoPlayer.initialSeekToStartTime = false;
videoPlayer.initialSeekToStartTime = false;
durationChange = false;
}
this.videoPlayer.seekToStartTimeOldSpeed = this.speed;
videoPlayer.seekToStartTimeOldSpeed = this.speed;
// Current startTime and endTime could have already been reset.
// We will remember their current values, and reset them at the
......@@ -681,22 +674,20 @@ function (HTML5Video, Resizer) {
// times so that the range on the slider gets correctly updated in
// the case of speed change in Flash player mode (for YouTube
// videos).
tempStartTime = this.videoPlayer.startTime;
tempEndTime = this.videoPlayer.endTime;
tempStartTime = videoPlayer.startTime;
tempEndTime = videoPlayer.endTime;
// We retrieve the original times. They could have been changed due
// to the fact of speed change (duration change). This happens when
// in YouTube Flash mode. There each speed is a different video,
// with a different length.
this.videoPlayer.startTime = this.config.startTime;
this.videoPlayer.endTime = this.config.endTime;
videoPlayer.startTime = this.config.startTime;
videoPlayer.endTime = this.config.endTime;
if (this.videoPlayer.startTime > duration) {
this.videoPlayer.startTime = 0;
} else {
if (this.currentPlayerMode === 'flash') {
this.videoPlayer.startTime /= Number(this.speed);
}
if (videoPlayer.startTime > duration) {
videoPlayer.startTime = 0;
} else if (this.currentPlayerMode === 'flash') {
videoPlayer.startTime /= Number(this.speed);
}
// An `endTime` of `null` means that either the user didn't set
......@@ -708,13 +699,10 @@ function (HTML5Video, Resizer) {
// sometimes in YouTube mode the duration changes slightly during
// the course of playback. This would cause the video to pause just
// before the actual end of the video.
if (
this.videoPlayer.endTime !== null &&
this.videoPlayer.endTime > duration
) {
this.videoPlayer.endTime = null;
} else if (this.videoPlayer.endTime !== null) {
if (this.currentPlayerMode === 'flash') {
if (videoPlayer.endTime !== null) {
if (videoPlayer.endTime > duration) {
this.videoPlayer.endTime = null;
} else if (this.currentPlayerMode === 'flash') {
this.videoPlayer.endTime /= Number(this.speed);
}
}
......@@ -734,9 +722,22 @@ function (HTML5Video, Resizer) {
// performed already such a seek.
if (
durationChange === false &&
this.videoPlayer.startTime > 0 &&
(videoPlayer.startTime > 0 || savedVideoPosition !== 0) &&
!(tempStartTime === 0 && tempEndTime === null)
) {
startTime = this.videoPlayer.startTime;
endTime = this.videoPlayer.endTime;
if (startTime) {
if (startTime < savedVideoPosition && endTime > savedVideoPosition) {
time = savedVideoPosition;
} else {
time = startTime;
}
} else {
time = savedVideoPosition;
}
// After a bug came up (BLD-708: "In Firefox YouTube video with
// start time plays from 00:00:00") the video refused to play
// from start time, and only played from the beginning.
......@@ -765,9 +766,10 @@ function (HTML5Video, Resizer) {
// times
this.videoPlayer.skipOnEndedStartEndReset = true;
this.videoPlayer.player.cueVideoById(youTubeId, this.videoPlayer.startTime);
this.videoPlayer.seekToTimeOnCued = time;
this.videoPlayer.player.cueVideoById(youTubeId, time);
} else {
this.videoPlayer.player.seekTo(this.videoPlayer.startTime);
this.videoPlayer.player.seekTo(time);
}
}
......@@ -775,8 +777,8 @@ function (HTML5Video, Resizer) {
// already reset (a seek event happened, the video already ended
// once, or endTime has already been reached once).
if (tempStartTime === 0 && tempEndTime === null) {
this.videoPlayer.startTime = 0;
this.videoPlayer.endTime = null;
videoPlayer.startTime = 0;
videoPlayer.endTime = null;
}
}
......
......@@ -104,7 +104,8 @@ function () {
// whole slider). Remember that endTime === null means the end time
// is set to the end of video by default.
function updateStartEndTimeRegion(params) {
var left, width, start, end, duration, rangeParams;
var isFlashMode = this.currentPlayerMode === 'flash',
left, width, start, end, duration, rangeParams;
// We must have a duration in order to determine the area of range.
// It also must be non-zero.
......@@ -119,22 +120,16 @@ function () {
if (start > duration) {
start = 0;
} else {
if (this.currentPlayerMode === 'flash') {
start /= Number(this.speed);
}
} else if (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
) {
if (end === null || end > duration) {
end = duration;
} else if (end !== null) {
if (this.currentPlayerMode === 'flash') {
end /= Number(this.speed);
}
} else if (isFlashMode) {
end /= Number(this.speed);
}
// Don't build a range if it takes up the whole slider.
......
......@@ -75,22 +75,13 @@ function (
window.Video = function (element) {
var state;
// Stop bufferization of previous video on sequence change.
// Problem: multiple video tags with the same src cannot
// play together. The second tag waiting when first video will be fully loaded.
// That's why we abort bufferization forcibly.
$(element).closest('.sequence').bind('sequence:change', function(e){
if (previousState !== null && typeof previousState.videoPlayer !== 'undefined') {
previousState.stopBuffering();
$(e.currentTarget).unbind('sequence:change');
}
});
// Check for existance of previous state, uninitialize it if necessary, and create a new state.
// Store new state for future invocation of this module consturctor function.
if (previousState !== null && typeof previousState.videoPlayer !== 'undefined') {
previousState.videoPlayer.onPause();
if (previousState && previousState.videoPlayer) {
previousState.saveState(true);
$(window).off('unload', previousState.saveState);
}
state = {};
previousState = state;
......@@ -110,6 +101,8 @@ function (
youtubeXhr = state.youtubeXhr;
}
$(element).find('.video').data('video-player-state', state);
// 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.
......
......@@ -30,7 +30,7 @@ from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent
from xmodule.exceptions import NotFoundError
from xblock.core import XBlock
from xblock.fields import Scope, String, Float, Boolean, List, Integer, ScopeIds
from xblock.fields import Scope, String, Float, Boolean, List, ScopeIds
from xmodule.fields import RelativeTime
from xmodule.modulestore.inheritance import InheritanceKeyValueStore
......@@ -46,10 +46,10 @@ class VideoFields(object):
default="Video",
scope=Scope.settings
)
position = Integer(
saved_video_position = RelativeTime(
help="Current position in the video",
scope=Scope.user_state,
default=0
default=datetime.timedelta(seconds=0)
)
show_captions = Boolean(
help="This controls whether or not captions are shown by default.",
......@@ -167,7 +167,7 @@ class VideoModule(VideoFields, XModule):
# index. We do that to avoid issues that occurs in tests.
js = {
'js': [
resource_string(__name__, 'js/src/video/00_cookie_storage.js'),
resource_string(__name__, 'js/src/video/00_video_storage.js'),
resource_string(__name__, 'js/src/video/00_resizer.js'),
resource_string(__name__, 'js/src/video/01_initialize.js'),
resource_string(__name__, 'js/src/video/025_focus_grabber.js'),
......@@ -186,15 +186,21 @@ class VideoModule(VideoFields, XModule):
js_module_name = "Video"
def handle_ajax(self, dispatch, data):
ACCEPTED_KEYS = ['speed']
accepted_keys = ['speed', 'saved_video_position']
if dispatch == 'save_user_state':
for key in data:
if hasattr(self, key) and key in ACCEPTED_KEYS:
setattr(self, key, json.loads(data[key]))
if hasattr(self, key) and key in accepted_keys:
if key == 'saved_video_position':
relative_position = RelativeTime.isotime_to_timedelta(data[key])
self.saved_video_position = relative_position
else:
setattr(self, key, json.loads(data[key]))
if key == 'speed':
self.global_speed = self.speed
log.debug(u"Test.")
return json.dumps({'success': True})
log.debug(u"GET {0}".format(data))
......@@ -235,6 +241,7 @@ class VideoModule(VideoFields, XModule):
'sources': sources,
'speed': json.dumps(self.speed),
'general_speed': self.global_speed,
'saved_video_position': self.saved_video_position.total_seconds(),
'start': self.start_time.total_seconds(),
'sub': self.sub,
'track': track_url,
......@@ -293,6 +300,7 @@ class VideoModule(VideoFields, XModule):
return response
class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor):
"""Descriptor for `VideoModule`."""
module_class = VideoModule
......
......@@ -2,6 +2,17 @@
Feature: LMS.Video component
As a student, I want to view course videos in LMS.
# 0
Scenario: Video component stores position correctly when page is reloaded
Given the course has a Video component in Youtube mode
Then when I view the video it has rendered in Youtube mode
And I click video button "play"
Then I seek video to "10" seconds
And I click video button "pause"
And I reload the page
And I click video button "play"
Then I see video starts playing from "0:10" position
# 1
Scenario: Video component is fully rendered in the LMS in HTML5 mode
Given the course has a Video component in HTML5 mode
......
......@@ -15,6 +15,16 @@ HTML5_SOURCES_INCORRECT = [
'https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.mp99'
]
VIDEO_BUTTONS = {
'CC': '.hide-subtitles',
'volume': '.volume',
'play': '.video_control.play',
'pause': '.video_control.pause',
}
# We should wait 300 ms for event handler invocation + 200ms for safety.
DELAY = 0.5
coursenum = 'test_course'
sequence = {}
......@@ -151,3 +161,26 @@ def _change_video_speed(speed):
world.browser.execute_script("$('.speeds').addClass('open')")
speed_css = 'li[data-speed="{0}"] a'.format(speed)
world.css_click(speed_css)
@step('I click video button "([^"]*)"$')
def click_button_video(_step, button_type):
world.wait(DELAY)
world.wait_for_ajax_complete()
button = button_type.strip()
world.css_click(VIDEO_BUTTONS[button])
@step('I see video starts playing from "([^"]*)" position$')
def start_playing_video_from_n_seconds(_step, position):
world.wait_for(
func=lambda _: world.css_html('.vidtime')[:4] == position.strip(),
timeout=5
)
@step('I seek video to "([^"]*)" seconds$')
def seek_video_to_n_seconds(_step, seconds):
time = float(seconds.strip())
jsCode = "$('.video').data('video-player-state').videoPlayer.onSlideSeek({{time: {0:f}}})".format(time)
world.browser.execute_script(jsCode)
......@@ -69,6 +69,7 @@ class TestVideoYouTube(TestVideo):
'speed': 'null',
'general_speed': 1.0,
'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson',
'track': None,
'youtube_streams': _create_youtube_string(self.item_module),
......@@ -124,6 +125,7 @@ class TestVideoNonYouTube(TestVideo):
'speed': 'null',
'general_speed': 1.0,
'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson',
'track': None,
'youtube_streams': '1.00:OEoXaMPEzfM',
......@@ -202,6 +204,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
u'webm': u'example.webm'
},
'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson',
'speed': 'null',
'general_speed': 1.0,
......@@ -308,6 +311,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
'speed': 'null',
'general_speed': 1.0,
'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson',
'track': None,
'youtube_streams': '1.00:OEoXaMPEzfM',
......
......@@ -22,6 +22,7 @@
data-show-captions="${show_captions}"
data-general-speed="${general_speed}"
data-speed="${speed}"
data-saved-video-position="${saved_video_position}"
data-start="${start}"
data-end="${end}"
data-caption-asset-path="${caption_asset_path}"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment