From 1d74838698849d863d3056bc1bea6b8160dcb748 Mon Sep 17 00:00:00 2001 From: polesye Date: Thu, 16 Jan 2014 12:46:32 +0200 Subject: [PATCH] BLD-237: Persist speed preferences between videos. --- CHANGELOG.rst | 2 + .../xmodule/xmodule/js/fixtures/video.html | 4 +- .../xmodule/js/fixtures/video_all.html | 2 + .../xmodule/js/fixtures/video_html5.html | 2 + .../js/fixtures/video_no_captions.html | 4 +- .../js/fixtures/video_yt_multiple.html | 4 +- common/lib/xmodule/xmodule/js/spec/helper.js | 6 + .../js/spec/video/cookie_storage_spec.js | 10 +- .../xmodule/js/spec/video/general_spec.js | 89 +++--- .../js/spec/video/video_player_spec.js | 12 +- .../js/spec/video/video_speed_control_spec.js | 2 +- .../xmodule/js/src/video/00_cookie_storage.js | 4 +- .../xmodule/js/src/video/01_initialize.js | 260 ++++++++++-------- .../xmodule/js/src/video/03_video_player.js | 24 +- .../xmodule/js/src/video/09_video_caption.js | 6 +- common/lib/xmodule/xmodule/video_module.py | 49 +++- .../courseware/features/video.feature | 21 ++ lms/djangoapps/courseware/features/video.py | 47 +++- .../courseware/tests/test_video_mongo.py | 28 +- .../courseware/tests/test_video_xml.py | 40 +-- lms/templates/video.html | 4 +- 21 files changed, 365 insertions(+), 255 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d41d47d750..fcce77bf4c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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: Video player persist speed preferences between videos. BLD-237. + Blades: Change the download video field to a dropdown that will allow students to download the first source listed in the alternate sources. BLD-364. diff --git a/common/lib/xmodule/xmodule/js/fixtures/video.html b/common/lib/xmodule/xmodule/js/fixtures/video.html index a28d10422a..a0bf54d363 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video.html @@ -4,8 +4,10 @@
0 and <= end time. - * - * An invalid start time will be reset to 0. An invalid end time will be - * set to `null`. It the task for the appropriate player API to figure out - * if start time and/or end time are greater than the length of the video. - */ - function checkStartEndTimes() { - this.config.startTime = parseInt(this.config.startTime, 10); - if (!isFinite(this.config.startTime) || this.config.startTime < 0) { - this.config.startTime = 0; - } - - this.config.endTime = parseInt(this.config.endTime, 10); - if ( - !isFinite(this.config.endTime) || - this.config.endTime <= this.config.startTime - ) { - this.config.endTime = null; - } + return __dfd__.promise(); } // function parseYoutubeStreams(state, youtubeStreams) @@ -595,22 +624,32 @@ function (VideoPlayer) { this.speeds = ($.map(this.videos, function (url, speed) { return speed; })).sort(); - - this.setSpeed($.cookie('video_speed')); } - function setSpeed(newSpeed, updateCookie) { - if (_.indexOf(this.speeds, newSpeed) !== -1) { + function setSpeed(newSpeed, updateStorage) { + // Possible speeds for each player type. + // flash = [0.75, 1, 1.25, 1.5] + // html5 = [0.75, 1, 1.25, 1.5] + // youtube html5 = [0.25, 0.5, 1, 1.5, 2] + var map = { + '0.25': '0.75', + '0.50': '0.75', + '0.75': '0.50', + '1.25': '1.50', + '2.0': '1.50' + }, + useSession = true; + + if (_.contains(this.speeds, newSpeed)) { this.speed = newSpeed; } else { - this.speed = '1.0'; + newSpeed = map[newSpeed]; + this.speed = _.contains(this.speeds, newSpeed) ? newSpeed : '1.0'; } - if (updateCookie) { - $.cookie('video_speed', this.speed, { - expires: 3650, - path: '/' - }); + if (updateStorage) { + this.storage.setItem('video_speed_' + this.id, this.speed, useSession); + this.storage.setItem('general_speed', this.speed, useSession); } } @@ -648,7 +687,11 @@ function (VideoPlayer) { } function getDuration() { - return this.metadata[this.youtubeId()].duration; + try { + return this.metadata[this.youtubeId()].duration; + } catch (err) { + return this.metadata[this.youtubeId('1.0')].duration; + } } /* @@ -662,8 +705,9 @@ function (VideoPlayer) { * * state.videoPlayer.pause({'param1': 10}); */ - function trigger(objChain, extraParameters) { - var i, tmpObj, chain; + function trigger(objChain) { + var extraParameters = Array.prototype.slice.call(arguments, 1), + i, tmpObj, chain; // Remember that 'this' is the 'state' object. tmpObj = this; @@ -685,7 +729,7 @@ function (VideoPlayer) { } } - tmpObj(extraParameters); + tmpObj.apply(this, extraParameters); return true; } diff --git a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js index 2fc434539a..cd47840646 100644 --- a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js +++ b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js @@ -324,7 +324,7 @@ function (HTML5Video, Resizer) { } } - function onSpeedChange(newSpeed, updateCookie) { + function onSpeedChange(newSpeed) { var time = this.videoPlayer.currentTime, methodName, youtubeId; @@ -347,7 +347,7 @@ function (HTML5Video, Resizer) { } ); - this.setSpeed(newSpeed, updateCookie); + this.setSpeed(newSpeed, true); if ( this.currentPlayerMode === 'html5' && @@ -376,6 +376,15 @@ function (HTML5Video, Resizer) { } this.el.trigger('speedchange', arguments); + + $.ajax({ + 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 @@ -434,7 +443,7 @@ function (HTML5Video, Resizer) { end: true }); - if (this.config.show_captions) { + if (this.config.showCaptions) { this.trigger('videoCaption.pause', null); } @@ -466,7 +475,7 @@ function (HTML5Video, Resizer) { this.trigger('videoControl.pause', null); - if (this.config.show_captions) { + if (this.config.showCaptions) { this.trigger('videoCaption.pause', null); } @@ -495,7 +504,7 @@ function (HTML5Video, Resizer) { end: false }); - if (this.config.show_captions) { + if (this.config.showCaptions) { this.trigger('videoCaption.play', null); } @@ -579,7 +588,6 @@ function (HTML5Video, Resizer) { var key = value.toFixed(2).replace(/\.00$/, '.0'); _this.videos[key] = baseSpeedSubs; - _this.speeds.push(key); }); @@ -590,8 +598,8 @@ function (HTML5Video, Resizer) { currentSpeed: this.speed } ); - - this.setSpeed($.cookie('video_speed')); + this.setSpeed(this.speed); + this.trigger('videoSpeedControl.setSpeed', this.speed); } } diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js index a89c459ca7..edea7bcdba 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js @@ -252,7 +252,7 @@ function () { } function captionURL() { - return '' + this.config.caption_asset_path + + return '' + this.config.captionAssetPath + this.youtubeId('1.0') + '.srt.sjson'; } @@ -356,7 +356,7 @@ function () { _this = this, autohideHtml5 = this.config.autohideHtml5; - this.elVideoWrapper.after(this.videoCaption.subtitlesEl); + this.container.after(this.videoCaption.subtitlesEl); this.el.find('.video-controls .secondary-controls') .append(this.videoCaption.hideSubtitlesEl); @@ -745,7 +745,7 @@ function () { 0.5 * this.videoControl.sliderEl.height() - 2 * paddingTop; } else { - return this.elVideoWrapper.height(); + return this.container.height(); } } diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 2df3e11e27..c6a698c5f2 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -20,7 +20,6 @@ import datetime import copy from webob import Response -from django.http import Http404 from django.conf import settings from xmodule.x_module import XModule, module_attr @@ -31,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, Boolean, List, Integer, ScopeIds +from xblock.fields import Scope, String, Float, Boolean, List, Integer, ScopeIds from xmodule.fields import RelativeTime from xmodule.modulestore.inheritance import InheritanceKeyValueStore @@ -137,6 +136,15 @@ class VideoFields(object): scope=Scope.settings, default="" ) + speed = Float( + help="The last speed that was explicitly set by user for the video.", + scope=Scope.user_state, + ) + global_speed = Float( + help="Default speed in cases when speed wasn't explicitly for specific video", + scope=Scope.preferences, + default=1.0 + ) class VideoModule(VideoFields, XModule): @@ -178,10 +186,21 @@ class VideoModule(VideoFields, XModule): js_module_name = "Video" def handle_ajax(self, dispatch, data): - """This is not being called right now and we raise 404 error.""" + ACCEPTED_KEYS = ['speed'] + + 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 key == 'speed': + self.global_speed = self.speed + + return json.dumps({'success': True}) + log.debug(u"GET {0}".format(data)) log.debug(u"DISPATCH {0}".format(dispatch)) - raise Http404() + + raise NotFoundError('Unexpected dispatch type') def get_html(self): track_url = None @@ -203,24 +222,26 @@ class VideoModule(VideoFields, XModule): track_url = self.runtime.handler_url(self, 'download_transcript') return self.system.render_template('video.html', { - 'youtube_streams': _create_youtube_string(self), - 'id': self.location.html_id(), - 'sub': self.sub, - 'sources': sources, - 'track': track_url, - 'display_name': self.display_name_with_default, + 'ajax_url': self.system.ajax_url + '/save_user_state', + 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False), # This won't work when we move to data that # isn't on the filesystem 'data_dir': getattr(self, 'data_dir', None), + 'display_name': self.display_name_with_default, 'caption_asset_path': caption_asset_path, - 'show_captions': json.dumps(self.show_captions), - 'start': self.start_time.total_seconds(), 'end': self.end_time.total_seconds(), - 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False), + 'id': self.location.html_id(), + 'show_captions': json.dumps(self.show_captions), + 'sources': sources, + 'speed': self.speed or self.global_speed, + 'start': self.start_time.total_seconds(), + 'sub': self.sub, + 'track': track_url, + 'youtube_streams': _create_youtube_string(self), # TODO: Later on the value 1500 should be taken from some global # configuration setting field. 'yt_test_timeout': 1500, - 'yt_test_url': settings.YOUTUBE_TEST_URL + 'yt_test_url': settings.YOUTUBE_TEST_URL, }) def get_transcript(self, subs_id): diff --git a/lms/djangoapps/courseware/features/video.feature b/lms/djangoapps/courseware/features/video.feature index 1ca82b02a1..39de72ba00 100644 --- a/lms/djangoapps/courseware/features/video.feature +++ b/lms/djangoapps/courseware/features/video.feature @@ -45,3 +45,24 @@ Feature: LMS.Video component Given the course has a Video component in HTML5_Unsupported_Video mode Then error message is shown And error message has correct text + + # 8 + Scenario: Video component stores speed correctly when each video is in separate sequence. + Given I am registered for the course "test_course" + And it has a video "A" in "Youtube" mode in position "1" of sequential + And a video "B" in "Youtube" mode in position "2" of sequential + And a video "C" in "Youtube" mode in position "3" of sequential + And I open the section with videos + And I select the "2.0" speed on video "A" + And I select the "0.50" speed on video "B" + When I open video "C" + Then video "C" should start playing at speed "0.50" + When I open video "A" + Then video "A" should start playing at speed "2.0" + And I reload the page + When I open video "A" + Then video "A" should start playing at speed "2.0" + When I open video "B" + Then video "B" should start playing at speed "0.50" + When I open video "C" + Then video "C" should start playing at speed "0.50" diff --git a/lms/djangoapps/courseware/features/video.py b/lms/djangoapps/courseware/features/video.py index 2f228853ab..c1c1437f34 100644 --- a/lms/djangoapps/courseware/features/video.py +++ b/lms/djangoapps/courseware/features/video.py @@ -15,6 +15,9 @@ HTML5_SOURCES_INCORRECT = [ 'https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.mp99' ] +coursenum = 'test_course' +sequence = {} + @step('when I view the (.*) it does not have autoplay enabled$') def does_not_autoplay(_step, video_type): assert(world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False') @@ -22,21 +25,48 @@ def does_not_autoplay(_step, video_type): @step('the course has a Video component in (.*) mode$') def view_video(_step, player_mode): - coursenum = 'test_course' - i_am_registered_for_the_course(step, coursenum) + + i_am_registered_for_the_course(_step, coursenum) # Make sure we have a video add_video_to_course(coursenum, player_mode.lower()) visit_scenario_item('SECTION') -def add_video_to_course(course, player_mode): +@step('a video "([^"]*)" in "([^"]*)" mode in position "([^"]*)" of sequential$') +def add_video(_step, player_id, player_mode, position): + sequence[player_id] = position + add_video_to_course(coursenum, player_mode.lower(), display_name=player_id) + + +@step('I open the section with videos$') +def visit_video_section(_step): + visit_scenario_item('SECTION') + + +@step('I select the "([^"]*)" speed on video "([^"]*)"$') +def change_video_speed(_step, speed, player_id): + _navigate_to_an_item_in_a_sequence(sequence[player_id]) + _change_video_speed(speed) + + +@step('I open video "([^"]*)"$') +def open_video(_step, player_id): + _navigate_to_an_item_in_a_sequence(sequence[player_id]) + + +@step('video "([^"]*)" should start playing at speed "([^"]*)"$') +def check_video_speed(_step, player_id, speed): + speed_css = '.speeds p.active' + assert world.css_has_text(speed_css, '{0}x'.format(speed)) + +def add_video_to_course(course, player_mode, display_name='Video'): category = 'video' kwargs = { 'parent_location': section_location(course), 'category': category, - 'display_name': 'Video' + 'display_name': display_name } if player_mode == 'html5': @@ -112,3 +142,12 @@ def error_message_has_correct_text(_step): assert world.css_has_text(selector, text) +def _navigate_to_an_item_in_a_sequence(number): + sequence_css = 'a[data-element="{0}"]'.format(number) + world.css_click(sequence_css) + + +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) diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py index a642855d10..21f9835c5c 100644 --- a/lms/djangoapps/courseware/tests/test_video_mongo.py +++ b/lms/djangoapps/courseware/tests/test_video_mongo.py @@ -19,6 +19,7 @@ from xmodule.exceptions import NotFoundError class TestVideo(BaseTestXmodule): """Integration tests: web client + mongo.""" + CATEGORY = "video" DATA = SOURCE_XML METADATA = {} @@ -57,6 +58,7 @@ class TestVideoYouTube(TestVideo): } expected_context = { + 'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'data_dir': getattr(self, 'data_dir', None), 'caption_asset_path': '/static/subs/', 'show_captions': 'true', @@ -64,6 +66,7 @@ class TestVideoYouTube(TestVideo): 'end': 3610.0, 'id': self.item_module.location.html_id(), 'sources': sources, + 'speed': 1.0, 'start': 3603.0, 'sub': u'a_sub_file.srt.sjson', 'track': None, @@ -75,7 +78,7 @@ class TestVideoYouTube(TestVideo): self.assertEqual( context, - self.item_module.xmodule_runtime.render_template('video.html', expected_context) + self.item_module.xmodule_runtime.render_template('video.html', expected_context), ) @@ -93,9 +96,10 @@ class TestVideoNonYouTube(TestVideo): """ MODEL_DATA = { - 'data': DATA + 'data': DATA, } METADATA = {} + def test_video_constructor(self): """Make sure that if the 'youtube' attribute is omitted in XML, then the template generates an empty string for the YouTube streams. @@ -107,8 +111,8 @@ class TestVideoNonYouTube(TestVideo): } context = self.item_module.render('student_view').content - expected_context = { + 'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'data_dir': getattr(self, 'data_dir', None), 'caption_asset_path': '/static/subs/', 'show_captions': 'true', @@ -116,6 +120,7 @@ class TestVideoNonYouTube(TestVideo): 'end': 3610.0, 'id': self.item_module.location.html_id(), 'sources': sources, + 'speed': 1.0, 'start': 3603.0, 'sub': u'a_sub_file.srt.sjson', 'track': None, @@ -127,7 +132,7 @@ class TestVideoNonYouTube(TestVideo): self.assertEqual( context, - self.item_module.xmodule_runtime.render_template('video.html', expected_context) + self.item_module.xmodule_runtime.render_template('video.html', expected_context), ) @@ -137,6 +142,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ''' CATEGORY = "video" DATA = SOURCE_XML + maxDiff = None METADATA = {} def setUp(self): @@ -195,7 +201,8 @@ class TestGetHtmlMethod(BaseTestXmodule): }, 'start': 3603.0, 'sub': u'a_sub_file.srt.sjson', - 'track': '', + 'speed': 1.0, + 'track': None, 'youtube_streams': '1.00:OEoXaMPEzfM', 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'yt_test_timeout': 1500, @@ -212,16 +219,18 @@ class TestGetHtmlMethod(BaseTestXmodule): self.initialize_module(data=DATA) track_url = self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'download_transcript') + context = self.item_module.render('student_view').content + expected_context.update({ + 'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'track': track_url if data['expected_track_url'] == u'a_sub_file.srt.sjson' else data['expected_track_url'], 'sub': data['sub'], 'id': self.item_module.location.html_id(), }) - context = self.item_module.render('student_view').content self.assertEqual( context, - self.item_module.xmodule_runtime.render_template('video.html', expected_context) + self.item_module.xmodule_runtime.render_template('video.html', expected_context), ) def test_get_html_source(self): @@ -293,6 +302,7 @@ class TestGetHtmlMethod(BaseTestXmodule): 'end': 3610.0, 'id': None, 'sources': None, + 'speed': 1.0, 'start': 3603.0, 'sub': u'a_sub_file.srt.sjson', 'track': None, @@ -309,14 +319,14 @@ class TestGetHtmlMethod(BaseTestXmodule): sources=data['sources'] ) self.initialize_module(data=DATA) + context = self.item_module.render('student_view').content expected_context.update({ + 'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'sources': data['result'], 'id': self.item_module.location.html_id(), }) - context = self.item_module.render('student_view').content - self.assertEqual( context, self.item_module.xmodule_runtime.render_template('video.html', expected_context) diff --git a/lms/djangoapps/courseware/tests/test_video_xml.py b/lms/djangoapps/courseware/tests/test_video_xml.py index 0e99928716..81573f6c10 100644 --- a/lms/djangoapps/courseware/tests/test_video_xml.py +++ b/lms/djangoapps/courseware/tests/test_video_xml.py @@ -15,11 +15,7 @@ common/lib/xmodule/xmodule/modulestore/tests/factories.py to create the course, section, subsection, unit, etc. """ -import unittest - -from django.conf import settings - -from xmodule.video_module import VideoDescriptor, _create_youtube_string +from xmodule.video_module import VideoDescriptor from xmodule.modulestore import Location from xmodule.tests import get_test_system, LogicTest, get_test_descriptor_system from xblock.field_data import DictFieldData @@ -63,40 +59,6 @@ class VideoFactory(object): return descriptor -class VideoModuleUnitTest(unittest.TestCase): - """Unit tests for Video Xmodule.""" - def test_video_get_html(self): - """Make sure that all parameters extracted correclty from xml""" - module = VideoFactory.create() - sources = { - 'main': 'example.mp4', - 'mp4': 'example.mp4', - 'webm': 'example.webm', - } - - expected_context = { - 'caption_asset_path': '/static/subs/', - 'sub': 'a_sub_file.srt.sjson', - 'data_dir': getattr(self, 'data_dir', None), - 'display_name': 'A Name', - 'end': 3610.0, - 'start': 3603.0, - 'id': module.location.html_id(), - 'show_captions': 'true', - 'sources': sources, - 'youtube_streams': _create_youtube_string(module), - 'track': None, - 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False), - 'yt_test_timeout': 1500, - 'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/' - } - - self.assertEqual( - module.render('student_view').content, - module.runtime.render_template('video.html', expected_context) - ) - - class VideoModuleLogicTest(LogicTest): """Tests for logic of Video Xmodule.""" diff --git a/lms/templates/video.html b/lms/templates/video.html index 4656e1f3b6..58c635c902 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -17,8 +17,10 @@ ${'data-webm-source="{}"'.format(sources.get('webm')) if sources.get('webm') else ''} ${'data-ogg-source="{}"'.format(sources.get('ogv')) if sources.get('ogv') else ''} + data-save-state-url="${ajax_url}" data-caption-data-dir="${data_dir}" data-show-captions="${show_captions}" + data-speed="${speed}" data-start="${start}" data-end="${end}" data-caption-asset-path="${caption_asset_path}" @@ -108,5 +110,3 @@ % endif
- -