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, ...@@ -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 in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. 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" Blades: Fix for the list metadata editor that gets into a bad state where "Add"
is disabled. BLD-821. is disabled. BLD-821.
......
...@@ -95,3 +95,54 @@ Feature: CMS.Video Component ...@@ -95,3 +95,54 @@ Feature: CMS.Video Component
And I save changes And I save changes
And I click video button "play" And I click video button "play"
Then I see a range on slider 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): ...@@ -198,3 +198,17 @@ def click_button_video(_step, button_type):
button = button_type.strip() button = button_type.strip()
world.css_click(VIDEO_BUTTONS[button]) 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): ...@@ -140,7 +140,8 @@ class RelativeTime(Field):
# Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types # Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types
MUTABLE = False 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. Validate that value in "HH:MM:SS" format and convert to timedelta.
...@@ -175,7 +176,7 @@ class RelativeTime(Field): ...@@ -175,7 +176,7 @@ class RelativeTime(Field):
return datetime.timedelta(seconds=value) return datetime.timedelta(seconds=value)
if isinstance(value, basestring): 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) msg = "RelativeTime Field {0} has bad value '{1!r}'".format(self._name, value)
raise TypeError(msg) raise TypeError(msg)
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
data-speed="1.5" data-speed="1.5"
data-start="" data-start=""
data-end="" data-end=""
data-saved-video-position="0"
data-caption-asset-path="/static/subs/" data-caption-asset-path="/static/subs/"
data-autoplay="False" data-autoplay="False"
data-yt-test-timeout="1500" data-yt-test-timeout="1500"
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
data-speed="1.5" data-speed="1.5"
data-start="" data-start=""
data-end="" data-end=""
data-saved-video-position="0"
data-caption-asset-path="/static/subs/" data-caption-asset-path="/static/subs/"
data-sub="Z5KLxerq05Y" data-sub="Z5KLxerq05Y"
data-mp4-source="xmodule/include/fixtures/test.mp4" data-mp4-source="xmodule/include/fixtures/test.mp4"
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
data-speed="1.5" data-speed="1.5"
data-start="" data-start=""
data-end="" data-end=""
data-saved-video-position="0"
data-caption-asset-path="/static/subs/" data-caption-asset-path="/static/subs/"
data-sub="Z5KLxerq05Y" data-sub="Z5KLxerq05Y"
data-mp4-source="xmodule/include/fixtures/test.mp4" data-mp4-source="xmodule/include/fixtures/test.mp4"
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
data-speed="1.5" data-speed="1.5"
data-start="" data-start=""
data-end="" data-end=""
data-saved-video-position="0"
data-caption-asset-path="/static/subs/" data-caption-asset-path="/static/subs/"
data-autoplay="False" data-autoplay="False"
data-yt-test-timeout="1500" data-yt-test-timeout="1500"
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
data-speed="1.5" data-speed="1.5"
data-start="" data-start=""
data-end="" data-end=""
data-saved-video-position="0"
data-caption-asset-path="/static/subs/" data-caption-asset-path="/static/subs/"
data-autoplay="False" data-autoplay="False"
data-yt-test-timeout="1500" data-yt-test-timeout="1500"
......
...@@ -207,18 +207,17 @@ ...@@ -207,18 +207,17 @@
beforeEach(function () { beforeEach(function () {
this.addMatchers({ this.addMatchers({
toHaveAttrs: function (attrs) { toHaveAttrs: function (attrs) {
var element = this.actual, var element;
result = true;
if ($.isEmptyObject(attrs)) { if ($.isEmptyObject(attrs)) {
return false; return false;
} }
$.each(attrs, function (name, value) { element = this.actual;
return result = result && element.attr(name) === value;
});
return result; return _.every(attrs, function (value, name) {
return element.attr(name) === value;
});
}, },
toBeInRange: function (min, max) { toBeInRange: function (min, max) {
return min <= this.actual && this.actual <= 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 @@ ...@@ -19,6 +19,7 @@
afterEach(function () { afterEach(function () {
$('source').remove(); $('source').remove();
window.onTouchBasedDevice = oldOTBD; window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
}); });
it('initialize', function () { it('initialize', function () {
......
...@@ -8,6 +8,8 @@ ...@@ -8,6 +8,8 @@
afterEach(function () { afterEach(function () {
$('source').remove(); $('source').remove();
window.VideoState = {};
window.VideoState.id = {};
}); });
describe('constructor', function () { describe('constructor', function () {
...@@ -80,7 +82,7 @@ ...@@ -80,7 +82,7 @@
'0.75': sub, '0.75': sub,
'1.0': sub, '1.0': sub,
'1.25': sub, '1.25': sub,
'1.5': sub '1.50': sub
}); });
}); });
...@@ -97,7 +99,7 @@ ...@@ -97,7 +99,7 @@
'0.75': sub, '0.75': sub,
'1.0': sub, '1.0': sub,
'1.25': sub, '1.25': sub,
'1.5': sub '1.50': sub
}); });
}); });
...@@ -227,10 +229,17 @@ ...@@ -227,10 +229,17 @@
expect(state.videoPlayer.skipOnEndedStartEndReset).toBe(true); 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 () { it('Handling cue state', function () {
spyOn(state.videoPlayer, 'play'); spyOn(state.videoPlayer, 'play');
state.videoPlayer.startTime = 10; state.videoPlayer.seekToTimeOnCued = 10;
state.videoPlayer.onStateChange({data: 5}); state.videoPlayer.onStateChange({data: 5});
expect(state.videoPlayer.player.seekTo).toHaveBeenCalledWith(10, true); expect(state.videoPlayer.player.seekTo).toHaveBeenCalledWith(10, true);
...@@ -397,7 +406,7 @@ ...@@ -397,7 +406,7 @@
it('save setting for new speed', function () { it('save setting for new speed', function () {
expect(state.storage.getItem('general_speed')).toBe('0.75'); 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 @@ ...@@ -9,7 +9,7 @@
}); });
afterEach(function () { afterEach(function () {
state = undefined; state.storage.clear();
$.fn.scrollTo.reset(); $.fn.scrollTo.reset();
$('.subtitles').remove(); $('.subtitles').remove();
$('source').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 @@ ...@@ -8,10 +8,7 @@
.andReturn(null); .andReturn(null);
state = jasmine.initializePlayer(); state = jasmine.initializePlayer();
videoControl = state.videoControl; videoControl = state.videoControl;
$.fn.scrollTo.reset();
}); });
afterEach(function () { afterEach(function () {
...@@ -21,6 +18,8 @@ ...@@ -21,6 +18,8 @@
// had before. Removing of `source` tag, not `video` tag, stops // had before. Removing of `source` tag, not `video` tag, stops
// loading video source and clears the memory. // loading video source and clears the memory.
$('source').remove(); $('source').remove();
$.fn.scrollTo.reset();
state.storage.clear();
window.onTouchBasedDevice = oldOTBD; window.onTouchBasedDevice = oldOTBD;
}); });
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
afterEach(function () { afterEach(function () {
$('source').remove(); $('source').remove();
state.storage.clear();
window.onTouchBasedDevice = oldOTBD; window.onTouchBasedDevice = oldOTBD;
}); });
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
afterEach(function () { afterEach(function () {
$('source').remove(); $('source').remove();
window.onTouchBasedDevice = oldOTBD; window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
}); });
describe('constructor', function () { describe('constructor', function () {
...@@ -213,10 +214,6 @@ ...@@ -213,10 +214,6 @@
); );
}); });
it('pause other video player', function () {
expect(oldState.videoPlayer.onPause).toHaveBeenCalled();
});
it('set update interval', function () { it('set update interval', function () {
expect(window.setInterval).toHaveBeenCalledWith( expect(window.setInterval).toHaveBeenCalledWith(
state.videoPlayer.update, 200 state.videoPlayer.update, 200
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
afterEach(function () { afterEach(function () {
$('source').remove(); $('source').remove();
window.onTouchBasedDevice = oldOTBD; window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
}); });
describe('constructor', function () { describe('constructor', function () {
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
afterEach(function () { afterEach(function () {
$('source').remove(); $('source').remove();
window.onTouchBasedDevice = oldOTBD; window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
}); });
describe('constructor', function () { describe('constructor', function () {
...@@ -20,9 +21,15 @@ ...@@ -20,9 +21,15 @@
}); });
it('render the quality control', function () { 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 () { it('add ARIA attributes to quality control', function () {
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
afterEach(function () { afterEach(function () {
$('source').remove(); $('source').remove();
window.onTouchBasedDevice = oldOTBD; window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
}); });
describe('constructor', function () { 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 @@ ...@@ -11,6 +11,7 @@
afterEach(function () { afterEach(function () {
$('source').remove(); $('source').remove();
window.onTouchBasedDevice = oldOTBD; window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
}); });
describe('constructor', function () { describe('constructor', function () {
......
...@@ -13,5 +13,18 @@ class @Time ...@@ -13,5 +13,18 @@ class @Time
else else
"#{minutes}:#{pad(seconds % 60)}" "#{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) -> @convert: (time, oldSpeed, newSpeed) ->
(time * oldSpeed / newSpeed).toFixed(3) (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 @@ ...@@ -14,8 +14,8 @@
define( define(
'video/01_initialize.js', 'video/01_initialize.js',
['video/03_video_player.js', 'video/00_cookie_storage.js'], ['video/03_video_player.js', 'video/00_video_storage.js'],
function (VideoPlayer, CookieStorage) { function (VideoPlayer, VideoStorage) {
/** /**
* @function * @function
* *
...@@ -26,7 +26,7 @@ function (VideoPlayer, CookieStorage) { ...@@ -26,7 +26,7 @@ function (VideoPlayer, CookieStorage) {
* available via this object. * available via this object.
* @param {DOM element} element Container of the entire Video DOM element. * @param {DOM element} element Container of the entire Video DOM element.
*/ */
return function (state, element) { var Initialize = function (state, element) {
_makeFunctionsPublic(state); _makeFunctionsPublic(state);
state.initialize(element) state.initialize(element)
...@@ -56,8 +56,26 @@ function (VideoPlayer, CookieStorage) { ...@@ -56,8 +56,26 @@ function (VideoPlayer, CookieStorage) {
state.el.trigger('initialize', arguments); 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. // Private functions start here. Private functions start with underscore.
// *************************************************************** // ***************************************************************
...@@ -73,21 +91,6 @@ function (VideoPlayer, CookieStorage) { ...@@ -73,21 +91,6 @@ function (VideoPlayer, CookieStorage) {
* methods, modules) of the Video player. * methods, modules) of the Video player.
*/ */
function _makeFunctionsPublic(state) { 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); bindTo(methodsDict, state, state);
} }
...@@ -201,7 +204,7 @@ function (VideoPlayer, CookieStorage) { ...@@ -201,7 +204,7 @@ function (VideoPlayer, CookieStorage) {
'0.75': state.config.sub, '0.75': state.config.sub,
'1.0': state.config.sub, '1.0': state.config.sub,
'1.25': 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. // We must have at least one non-YouTube video source available.
...@@ -271,13 +274,13 @@ function (VideoPlayer, CookieStorage) { ...@@ -271,13 +274,13 @@ function (VideoPlayer, CookieStorage) {
return dfd.promise(); return dfd.promise();
} }
function _getConfiguration(data) { function _getConfiguration(data, storage) {
var isBoolean = function (value) { var isBoolean = function (value) {
var regExp = /^true$/i; var regExp = /^true$/i;
return regExp.test(value.toString()); return regExp.test(value.toString());
}, },
// List of keys that will be extracted form the configuration. // List of keys that will be extracted form the configuration.
extractKeys = ['speed'], extractKeys = [],
// Compatibility keys used to change names of some parameters in // Compatibility keys used to change names of some parameters in
// the final configuration. // the final configuration.
compatKeys = { compatKeys = {
...@@ -289,6 +292,19 @@ function (VideoPlayer, CookieStorage) { ...@@ -289,6 +292,19 @@ function (VideoPlayer, CookieStorage) {
'showCaptions': isBoolean, 'showCaptions': isBoolean,
'autoplay': isBoolean, 'autoplay': isBoolean,
'autohideHtml5': 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) { 'ytTestTimeout': function (value) {
value = parseInt(value, 10); value = parseInt(value, 10);
...@@ -380,12 +396,7 @@ function (VideoPlayer, CookieStorage) { ...@@ -380,12 +396,7 @@ function (VideoPlayer, CookieStorage) {
id = el.attr('id').replace(/video_/, ''), id = el.attr('id').replace(/video_/, ''),
__dfd__ = $.Deferred(), __dfd__ = $.Deferred(),
isTouch = onTouchBasedDevice() || '', isTouch = onTouchBasedDevice() || '',
storage = CookieStorage('video_player'), storage = VideoStorage('VideoState', id);
speed = storage.getItem('video_speed_' + id) ||
el.data('speed') ||
storage.getItem('general_speed') ||
el.data('general-speed') ||
'1.0';
if (isTouch) { if (isTouch) {
el.addClass('is-touch'); el.addClass('is-touch');
...@@ -399,7 +410,6 @@ function (VideoPlayer, CookieStorage) { ...@@ -399,7 +410,6 @@ function (VideoPlayer, CookieStorage) {
id: id, id: id,
isFullScreen: false, isFullScreen: false,
isTouch: isTouch, isTouch: isTouch,
speed: Number(speed).toFixed(2).replace(/\.00$/, '.0'),
storage: storage storage: storage
}); });
...@@ -411,7 +421,7 @@ function (VideoPlayer, CookieStorage) { ...@@ -411,7 +421,7 @@ function (VideoPlayer, CookieStorage) {
// are "read only", so don't modify them. All variable content lives in // are "read only", so don't modify them. All variable content lives in
// 'state' object. // 'state' object.
// jQuery .data() return object with keys in lower camelCase format. // 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, element: element,
fadeOutTimeout: 1400, fadeOutTimeout: 1400,
captionsFreezeTime: 10000, captionsFreezeTime: 10000,
...@@ -422,6 +432,10 @@ function (VideoPlayer, CookieStorage) { ...@@ -422,6 +432,10 @@ function (VideoPlayer, CookieStorage) {
this.config.endTime = null; this.config.endTime = null;
} }
this.speed = Number(
this.config.speed || this.config.generalSpeed
).toFixed(2).replace(/\.00$/, '.0');
if (!(_parseYouTubeIDs(this))) { if (!(_parseYouTubeIDs(this))) {
// If we do not have YouTube ID's, try parsing HTML5 video sources. // If we do not have YouTube ID's, try parsing HTML5 video sources.
...@@ -637,8 +651,8 @@ function (VideoPlayer, CookieStorage) { ...@@ -637,8 +651,8 @@ function (VideoPlayer, CookieStorage) {
} }
if (updateStorage) { if (updateStorage) {
this.storage.setItem('video_speed_' + this.id, this.speed, useSession); this.storage.setItem('speed', this.speed, true);
this.storage.setItem('general_speed', this.speed, useSession); this.storage.setItem('general_speed', this.speed);
} }
} }
...@@ -659,20 +673,34 @@ function (VideoPlayer, CookieStorage) { ...@@ -659,20 +673,34 @@ function (VideoPlayer, CookieStorage) {
return xhr; return xhr;
} }
function stopBuffering() { function saveState(async, data) {
var video; if (!($.isPlainObject(data))) {
data = {
saved_video_position: this.videoPlayer.currentTime
};
}
if (this.videoType === 'html5') { if (data.speed) {
// HTML5 player haven't default way to abort bufferization. this.storage.setItem('speed', data.speed, true);
// In this case we simply resetting source and call load().
video = this.videoPlayer.player.video;
video.src = '';
video.load();
} }
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) { function youtubeId(speed) {
return this.videos[speed || this.speed]; return this.videos[speed || this.speed] || this.videos['1.0'];
} }
function getDuration() { function getDuration() {
......
...@@ -67,6 +67,8 @@ function (HTML5Video, Resizer) { ...@@ -67,6 +67,8 @@ function (HTML5Video, Resizer) {
// metadata is loaded, which normally happens just after the video // metadata is loaded, which normally happens just after the video
// starts playing. Just after that configurations can be applied. // starts playing. Just after that configurations can be applied.
state.videoPlayer.ready = _.once(function () { state.videoPlayer.ready = _.once(function () {
$(window).on('unload', state.saveState);
if (state.currentPlayerMode !== 'flash') { if (state.currentPlayerMode !== 'flash') {
state.videoPlayer.onSpeedChange(state.speed); state.videoPlayer.onSpeedChange(state.speed);
} }
...@@ -292,8 +294,7 @@ function (HTML5Video, Resizer) { ...@@ -292,8 +294,7 @@ function (HTML5Video, Resizer) {
// (currentTime) and its duration. // (currentTime) and its duration.
// It is called at a regular interval when the video is playing. // It is called at a regular interval when the video is playing.
function update() { function update() {
this.videoPlayer.currentTime = this.videoPlayer.player this.videoPlayer.currentTime = this.videoPlayer.player.getCurrentTime();
.getCurrentTime();
if (isFinite(this.videoPlayer.currentTime)) { if (isFinite(this.videoPlayer.currentTime)) {
this.videoPlayer.updatePlayTime(this.videoPlayer.currentTime); this.videoPlayer.updatePlayTime(this.videoPlayer.currentTime);
...@@ -379,14 +380,7 @@ function (HTML5Video, Resizer) { ...@@ -379,14 +380,7 @@ function (HTML5Video, Resizer) {
this.el.trigger('speedchange', arguments); this.el.trigger('speedchange', arguments);
$.ajax({ this.saveState(true, { speed: newSpeed });
url: this.config.saveStateUrl,
type: 'POST',
dataType: 'json',
data: {
speed: newSpeed
},
});
} }
// Every 200 ms, if the video is playing, we call the function update, via // Every 200 ms, if the video is playing, we call the function update, via
...@@ -485,6 +479,7 @@ function (HTML5Video, Resizer) { ...@@ -485,6 +479,7 @@ function (HTML5Video, Resizer) {
this.trigger('videoCaption.pause', null); this.trigger('videoCaption.pause', null);
} }
this.saveState(true);
this.el.trigger('pause', arguments); this.el.trigger('pause', arguments);
} }
...@@ -640,7 +635,7 @@ function (HTML5Video, Resizer) { ...@@ -640,7 +635,7 @@ function (HTML5Video, Resizer) {
this.videoPlayer.onEnded(); this.videoPlayer.onEnded();
break; break;
case this.videoPlayer.PlayerState.CUED: 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 // We need to call play() explicitly because after the call
// to functions cueVideoById() followed by seekTo() the video // to functions cueVideoById() followed by seekTo() the video
// is in a PAUSED state. // is in a PAUSED state.
...@@ -652,28 +647,26 @@ function (HTML5Video, Resizer) { ...@@ -652,28 +647,26 @@ function (HTML5Video, Resizer) {
} }
function updatePlayTime(time) { function updatePlayTime(time) {
var duration = this.videoPlayer.duration(), var videoPlayer = this.videoPlayer,
durationChange, tempStartTime, tempEndTime, youTubeId; duration = this.videoPlayer.duration(),
savedVideoPosition = this.config.savedVideoPosition,
isNewSpeed = videoPlayer.seekToStartTimeOldSpeed !== this.speed,
durationChange, tempStartTime, tempEndTime, youTubeId,
startTime, endTime;
if ( if (
duration > 0 && duration > 0 &&
( (isNewSpeed || videoPlayer.initialSeekToStartTime)
this.videoPlayer.seekToStartTimeOldSpeed !== this.speed ||
this.videoPlayer.initialSeekToStartTime
)
) { ) {
if ( if (isNewSpeed && videoPlayer.initialSeekToStartTime === false) {
this.videoPlayer.seekToStartTimeOldSpeed !== this.speed &&
this.videoPlayer.initialSeekToStartTime === false
) {
durationChange = true; durationChange = true;
} else { // this.videoPlayer.initialSeekToStartTime === true } else { // this.videoPlayer.initialSeekToStartTime === true
this.videoPlayer.initialSeekToStartTime = false; videoPlayer.initialSeekToStartTime = false;
durationChange = false; durationChange = false;
} }
this.videoPlayer.seekToStartTimeOldSpeed = this.speed; videoPlayer.seekToStartTimeOldSpeed = this.speed;
// Current startTime and endTime could have already been reset. // Current startTime and endTime could have already been reset.
// We will remember their current values, and reset them at the // We will remember their current values, and reset them at the
...@@ -681,22 +674,20 @@ function (HTML5Video, Resizer) { ...@@ -681,22 +674,20 @@ function (HTML5Video, Resizer) {
// times so that the range on the slider gets correctly updated in // times so that the range on the slider gets correctly updated in
// the case of speed change in Flash player mode (for YouTube // the case of speed change in Flash player mode (for YouTube
// videos). // videos).
tempStartTime = this.videoPlayer.startTime; tempStartTime = videoPlayer.startTime;
tempEndTime = this.videoPlayer.endTime; tempEndTime = videoPlayer.endTime;
// We retrieve the original times. They could have been changed due // We retrieve the original times. They could have been changed due
// to the fact of speed change (duration change). This happens when // to the fact of speed change (duration change). This happens when
// in YouTube Flash mode. There each speed is a different video, // in YouTube Flash mode. There each speed is a different video,
// with a different length. // with a different length.
this.videoPlayer.startTime = this.config.startTime; videoPlayer.startTime = this.config.startTime;
this.videoPlayer.endTime = this.config.endTime; videoPlayer.endTime = this.config.endTime;
if (this.videoPlayer.startTime > duration) { if (videoPlayer.startTime > duration) {
this.videoPlayer.startTime = 0; videoPlayer.startTime = 0;
} else { } else if (this.currentPlayerMode === 'flash') {
if (this.currentPlayerMode === 'flash') { videoPlayer.startTime /= Number(this.speed);
this.videoPlayer.startTime /= Number(this.speed);
}
} }
// An `endTime` of `null` means that either the user didn't set // An `endTime` of `null` means that either the user didn't set
...@@ -708,13 +699,10 @@ function (HTML5Video, Resizer) { ...@@ -708,13 +699,10 @@ function (HTML5Video, Resizer) {
// sometimes in YouTube mode the duration changes slightly during // sometimes in YouTube mode the duration changes slightly during
// the course of playback. This would cause the video to pause just // the course of playback. This would cause the video to pause just
// before the actual end of the video. // before the actual end of the video.
if ( if (videoPlayer.endTime !== null) {
this.videoPlayer.endTime !== null && if (videoPlayer.endTime > duration) {
this.videoPlayer.endTime > duration this.videoPlayer.endTime = null;
) { } else if (this.currentPlayerMode === 'flash') {
this.videoPlayer.endTime = null;
} else if (this.videoPlayer.endTime !== null) {
if (this.currentPlayerMode === 'flash') {
this.videoPlayer.endTime /= Number(this.speed); this.videoPlayer.endTime /= Number(this.speed);
} }
} }
...@@ -734,9 +722,22 @@ function (HTML5Video, Resizer) { ...@@ -734,9 +722,22 @@ function (HTML5Video, Resizer) {
// performed already such a seek. // performed already such a seek.
if ( if (
durationChange === false && durationChange === false &&
this.videoPlayer.startTime > 0 && (videoPlayer.startTime > 0 || savedVideoPosition !== 0) &&
!(tempStartTime === 0 && tempEndTime === null) !(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 // After a bug came up (BLD-708: "In Firefox YouTube video with
// start time plays from 00:00:00") the video refused to play // start time plays from 00:00:00") the video refused to play
// from start time, and only played from the beginning. // from start time, and only played from the beginning.
...@@ -765,9 +766,10 @@ function (HTML5Video, Resizer) { ...@@ -765,9 +766,10 @@ function (HTML5Video, Resizer) {
// times // times
this.videoPlayer.skipOnEndedStartEndReset = true; this.videoPlayer.skipOnEndedStartEndReset = true;
this.videoPlayer.player.cueVideoById(youTubeId, this.videoPlayer.startTime); this.videoPlayer.seekToTimeOnCued = time;
this.videoPlayer.player.cueVideoById(youTubeId, time);
} else { } else {
this.videoPlayer.player.seekTo(this.videoPlayer.startTime); this.videoPlayer.player.seekTo(time);
} }
} }
...@@ -775,8 +777,8 @@ function (HTML5Video, Resizer) { ...@@ -775,8 +777,8 @@ function (HTML5Video, Resizer) {
// already reset (a seek event happened, the video already ended // already reset (a seek event happened, the video already ended
// once, or endTime has already been reached once). // once, or endTime has already been reached once).
if (tempStartTime === 0 && tempEndTime === null) { if (tempStartTime === 0 && tempEndTime === null) {
this.videoPlayer.startTime = 0; videoPlayer.startTime = 0;
this.videoPlayer.endTime = null; videoPlayer.endTime = null;
} }
} }
......
...@@ -104,7 +104,8 @@ function () { ...@@ -104,7 +104,8 @@ function () {
// whole slider). Remember that endTime === null means the end time // whole slider). Remember that endTime === null means the end time
// is set to the end of video by default. // is set to the end of video by default.
function updateStartEndTimeRegion(params) { 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. // We must have a duration in order to determine the area of range.
// It also must be non-zero. // It also must be non-zero.
...@@ -119,22 +120,16 @@ function () { ...@@ -119,22 +120,16 @@ function () {
if (start > duration) { if (start > duration) {
start = 0; start = 0;
} else { } else if (isFlashMode) {
if (this.currentPlayerMode === 'flash') { start /= Number(this.speed);
start /= Number(this.speed);
}
} }
// If end is set to null, or it is greater than the duration of the // 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. // video, then we set it to the end of the video.
if ( if (end === null || end > duration) {
end === null || end > duration
) {
end = duration; end = duration;
} else if (end !== null) { } else if (isFlashMode) {
if (this.currentPlayerMode === 'flash') { end /= Number(this.speed);
end /= Number(this.speed);
}
} }
// Don't build a range if it takes up the whole slider. // Don't build a range if it takes up the whole slider.
......
...@@ -75,22 +75,13 @@ function ( ...@@ -75,22 +75,13 @@ function (
window.Video = function (element) { window.Video = function (element) {
var state; 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. // 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. // Store new state for future invocation of this module consturctor function.
if (previousState !== null && typeof previousState.videoPlayer !== 'undefined') { if (previousState && previousState.videoPlayer) {
previousState.videoPlayer.onPause(); previousState.saveState(true);
$(window).off('unload', previousState.saveState);
} }
state = {}; state = {};
previousState = state; previousState = state;
...@@ -110,6 +101,8 @@ function ( ...@@ -110,6 +101,8 @@ function (
youtubeXhr = state.youtubeXhr; 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 // 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 // it available to the caller by returning it. This is necessary so that we can test
// Video with Jasmine. // Video with Jasmine.
......
...@@ -30,7 +30,7 @@ from xmodule.contentstore.django import contentstore ...@@ -30,7 +30,7 @@ from xmodule.contentstore.django import contentstore
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
from xblock.core import XBlock 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.fields import RelativeTime
from xmodule.modulestore.inheritance import InheritanceKeyValueStore from xmodule.modulestore.inheritance import InheritanceKeyValueStore
...@@ -46,10 +46,10 @@ class VideoFields(object): ...@@ -46,10 +46,10 @@ class VideoFields(object):
default="Video", default="Video",
scope=Scope.settings scope=Scope.settings
) )
position = Integer( saved_video_position = RelativeTime(
help="Current position in the video", help="Current position in the video",
scope=Scope.user_state, scope=Scope.user_state,
default=0 default=datetime.timedelta(seconds=0)
) )
show_captions = Boolean( show_captions = Boolean(
help="This controls whether or not captions are shown by default.", help="This controls whether or not captions are shown by default.",
...@@ -167,7 +167,7 @@ class VideoModule(VideoFields, XModule): ...@@ -167,7 +167,7 @@ class VideoModule(VideoFields, XModule):
# index. We do that to avoid issues that occurs in tests. # index. We do that to avoid issues that occurs in tests.
js = { js = {
'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/00_resizer.js'),
resource_string(__name__, 'js/src/video/01_initialize.js'), resource_string(__name__, 'js/src/video/01_initialize.js'),
resource_string(__name__, 'js/src/video/025_focus_grabber.js'), resource_string(__name__, 'js/src/video/025_focus_grabber.js'),
...@@ -186,15 +186,21 @@ class VideoModule(VideoFields, XModule): ...@@ -186,15 +186,21 @@ class VideoModule(VideoFields, XModule):
js_module_name = "Video" js_module_name = "Video"
def handle_ajax(self, dispatch, data): def handle_ajax(self, dispatch, data):
ACCEPTED_KEYS = ['speed'] accepted_keys = ['speed', 'saved_video_position']
if dispatch == 'save_user_state': if dispatch == 'save_user_state':
for key in data: for key in data:
if hasattr(self, key) and key in ACCEPTED_KEYS: if hasattr(self, key) and key in accepted_keys:
setattr(self, key, json.loads(data[key])) 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': if key == 'speed':
self.global_speed = self.speed self.global_speed = self.speed
log.debug(u"Test.")
return json.dumps({'success': True}) return json.dumps({'success': True})
log.debug(u"GET {0}".format(data)) log.debug(u"GET {0}".format(data))
...@@ -235,6 +241,7 @@ class VideoModule(VideoFields, XModule): ...@@ -235,6 +241,7 @@ class VideoModule(VideoFields, XModule):
'sources': sources, 'sources': sources,
'speed': json.dumps(self.speed), 'speed': json.dumps(self.speed),
'general_speed': self.global_speed, 'general_speed': self.global_speed,
'saved_video_position': self.saved_video_position.total_seconds(),
'start': self.start_time.total_seconds(), 'start': self.start_time.total_seconds(),
'sub': self.sub, 'sub': self.sub,
'track': track_url, 'track': track_url,
...@@ -293,6 +300,7 @@ class VideoModule(VideoFields, XModule): ...@@ -293,6 +300,7 @@ class VideoModule(VideoFields, XModule):
return response return response
class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor): class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor):
"""Descriptor for `VideoModule`.""" """Descriptor for `VideoModule`."""
module_class = VideoModule module_class = VideoModule
......
...@@ -2,6 +2,17 @@ ...@@ -2,6 +2,17 @@
Feature: LMS.Video component Feature: LMS.Video component
As a student, I want to view course videos in LMS. 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 # 1
Scenario: Video component is fully rendered in the LMS in HTML5 mode Scenario: Video component is fully rendered in the LMS in HTML5 mode
Given the course has a Video component in HTML5 mode Given the course has a Video component in HTML5 mode
......
...@@ -15,6 +15,16 @@ HTML5_SOURCES_INCORRECT = [ ...@@ -15,6 +15,16 @@ HTML5_SOURCES_INCORRECT = [
'https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.mp99' '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' coursenum = 'test_course'
sequence = {} sequence = {}
...@@ -151,3 +161,26 @@ def _change_video_speed(speed): ...@@ -151,3 +161,26 @@ def _change_video_speed(speed):
world.browser.execute_script("$('.speeds').addClass('open')") world.browser.execute_script("$('.speeds').addClass('open')")
speed_css = 'li[data-speed="{0}"] a'.format(speed) speed_css = 'li[data-speed="{0}"] a'.format(speed)
world.css_click(speed_css) 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): ...@@ -69,6 +69,7 @@ class TestVideoYouTube(TestVideo):
'speed': 'null', 'speed': 'null',
'general_speed': 1.0, 'general_speed': 1.0,
'start': 3603.0, 'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson', 'sub': u'a_sub_file.srt.sjson',
'track': None, 'track': None,
'youtube_streams': _create_youtube_string(self.item_module), 'youtube_streams': _create_youtube_string(self.item_module),
...@@ -124,6 +125,7 @@ class TestVideoNonYouTube(TestVideo): ...@@ -124,6 +125,7 @@ class TestVideoNonYouTube(TestVideo):
'speed': 'null', 'speed': 'null',
'general_speed': 1.0, 'general_speed': 1.0,
'start': 3603.0, 'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson', 'sub': u'a_sub_file.srt.sjson',
'track': None, 'track': None,
'youtube_streams': '1.00:OEoXaMPEzfM', 'youtube_streams': '1.00:OEoXaMPEzfM',
...@@ -202,6 +204,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -202,6 +204,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
u'webm': u'example.webm' u'webm': u'example.webm'
}, },
'start': 3603.0, 'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson', 'sub': u'a_sub_file.srt.sjson',
'speed': 'null', 'speed': 'null',
'general_speed': 1.0, 'general_speed': 1.0,
...@@ -308,6 +311,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -308,6 +311,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
'speed': 'null', 'speed': 'null',
'general_speed': 1.0, 'general_speed': 1.0,
'start': 3603.0, 'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson', 'sub': u'a_sub_file.srt.sjson',
'track': None, 'track': None,
'youtube_streams': '1.00:OEoXaMPEzfM', 'youtube_streams': '1.00:OEoXaMPEzfM',
......
...@@ -22,6 +22,7 @@ ...@@ -22,6 +22,7 @@
data-show-captions="${show_captions}" data-show-captions="${show_captions}"
data-general-speed="${general_speed}" data-general-speed="${general_speed}"
data-speed="${speed}" data-speed="${speed}"
data-saved-video-position="${saved_video_position}"
data-start="${start}" data-start="${start}"
data-end="${end}" data-end="${end}"
data-caption-asset-path="${caption_asset_path}" 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