From 25407ef3e73ab947114256c53d2e6ff264c99dd0 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Thu, 23 Jan 2014 15:26:06 +0200 Subject: [PATCH] BLD-642: Allow multiple transcripts with video. --- CHANGELOG.rst | 2 + .../component_settings_editor_helpers.py | 2 +- .../contentstore/features/transcripts.feature | 89 +-- .../contentstore/features/transcripts.py | 4 +- .../features/video-editor.feature | 12 +- .../contentstore/features/video-editor.py | 9 +- .../contentstore/features/video.feature | 38 +- cms/djangoapps/contentstore/features/video.py | 7 + .../contentstore/tests/test_contentstore.py | 9 - .../tests/test_transcripts_utils.py | 86 ++- cms/djangoapps/contentstore/views/item.py | 7 +- .../views/tests/test_transcripts.py | 2 +- .../contentstore/views/transcripts_ajax.py | 21 +- cms/envs/common.py | 4 +- .../xmodule/xmodule/css/video/display.scss | 320 +++++----- .../xmodule/xmodule/js/fixtures/video.html | 9 +- .../xmodule/js/fixtures/video_all.html | 9 +- .../xmodule/js/fixtures/video_html5.html | 5 +- .../js/fixtures/video_no_captions.html | 5 +- .../js/fixtures/video_yt_multiple.html | 29 +- common/lib/xmodule/xmodule/js/spec/helper.js | 61 +- .../xmodule/js/spec/video/general_spec.js | 103 ---- .../xmodule/js/spec/video/initialize_spec.js | 217 ++++++- .../js/spec/video/video_caption_spec.js | 489 +++++++++++---- .../js/spec/video/video_player_spec.js | 226 ++++--- .../xmodule/js/src/video/01_initialize.js | 45 +- .../xmodule/js/src/video/03_video_player.js | 100 +-- .../xmodule/js/src/video/09_video_caption.js | 569 +++++++++--------- common/lib/xmodule/xmodule/tests/__init__.py | 2 +- .../lib/xmodule/xmodule/tests/test_video.py | 26 +- .../xmodule/xmodule/video_module/__init__.py | 10 + .../video_module}/transcripts_utils.py | 215 +++++-- .../{ => video_module}/video_module.py | 368 ++++++----- .../xmodule/video_module/video_utils.py | 25 + .../uploads/zh_subs_OEoXaMPEzfM.srt.sjson | 17 + .../courseware/features/video.feature | 18 +- lms/djangoapps/courseware/features/video.py | 185 ++++-- lms/djangoapps/courseware/tests/__init__.py | 8 +- .../courseware/tests/test_lti_integration.py | 22 +- .../courseware/tests/test_video_handlers.py | 351 +++++++++++ .../courseware/tests/test_video_mongo.py | 278 ++------- .../courseware/tests/test_video_xml.py | 1 + .../courseware/tests/test_word_cloud.py | 8 +- lms/envs/common.py | 190 ++++++ lms/templates/video.html | 14 +- 45 files changed, 2730 insertions(+), 1487 deletions(-) create mode 100644 common/lib/xmodule/xmodule/video_module/__init__.py rename {cms/djangoapps/contentstore => common/lib/xmodule/xmodule/video_module}/transcripts_utils.py (61%) rename common/lib/xmodule/xmodule/{ => video_module}/video_module.py (67%) create mode 100644 common/lib/xmodule/xmodule/video_module/video_utils.py create mode 100644 common/test/data/uploads/zh_subs_OEoXaMPEzfM.srt.sjson create mode 100644 lms/djangoapps/courseware/tests/test_video_handlers.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c958366b7d..00ab686c20 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: Allow multiple transcripts with video. BLD-642. + CMS: Add feature to allow exporting a course to a git repository by specifying the giturl in the course settings. diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index b194e356bd..9a590572a3 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -145,7 +145,7 @@ def verify_setting_entry(setting, display_name, value, explicitly_set): # Check if the web object is a list type # If so, we use a slightly different mechanism for determining its value - if setting.has_class('metadata-list-enum'): + if setting.has_class('metadata-list-enum') or setting.has_class('metadata-dict'): list_value = ', '.join(ele.value for ele in setting.find_by_css('.list-settings-item')) assert_equal(value, list_value) elif setting.has_class('metadata-videolist-enum'): diff --git a/cms/djangoapps/contentstore/features/transcripts.feature b/cms/djangoapps/contentstore/features/transcripts.feature index 72f2beff3a..bd0a6efc1b 100644 --- a/cms/djangoapps/contentstore/features/transcripts.feature +++ b/cms/djangoapps/contentstore/features/transcripts.feature @@ -1,6 +1,6 @@ @shard_3 -Feature: CMS.Transcripts - As a course author, I want to be able to create video components. +Feature: CMS Transcripts + As a course author, I want to be able to create video components # For transcripts acceptance tests there are 3 available caption # files. They can be used to test various transcripts features. Two of @@ -72,7 +72,7 @@ Feature: CMS.Transcripts And I remove "t_not_exist" transcripts id from store And I enter a "http://youtu.be/t_not_exist" source to field number 1 Then I see status message "not found" - And I see value "" in the field "HTML5 Transcript" + And I see value "" in the field "Transcript (primary)" # Import: w/o local but with server subs And I remove "t__eq_exist" transcripts id from store @@ -83,7 +83,7 @@ Feature: CMS.Transcripts Then I see status message "found" And I see button "upload_new_timed_transcripts" And I see button "download_to_edit" - And I see value "t__eq_exist" in the field "HTML5 Transcript" + And I see value "t__eq_exist" in the field "Transcript (primary)" #4 Scenario: Youtube id only: check "Found" state @@ -92,7 +92,7 @@ Feature: CMS.Transcripts And I enter a "http://youtu.be/t_not_exist" source to field number 1 Then I see status message "found" - And I see value "t_not_exist" in the field "HTML5 Transcript" + And I see value "t_not_exist" in the field "Transcript (primary)" #5 Scenario: Youtube id only: check "Found" state when user sets youtube_id with local and server subs and they are equal @@ -102,7 +102,7 @@ Feature: CMS.Transcripts And I enter a "http://youtu.be/t__eq_exist" source to field number 1 And I see status message "found" - And I see value "t__eq_exist" in the field "HTML5 Transcript" + And I see value "t__eq_exist" in the field "Transcript (primary)" #6 Scenario: Youtube id only: check "Found" state when user sets youtube_id with local and server subs and they are not equal @@ -114,7 +114,7 @@ Feature: CMS.Transcripts And I see button "replace" And I click transcript button "replace" And I see status message "found" - And I see value "t_neq_exist" in the field "HTML5 Transcript" + And I see value "t_neq_exist" in the field "Transcript (primary)" #7 Scenario: html5 source only: check "Not Found" state @@ -123,7 +123,7 @@ Feature: CMS.Transcripts And I enter a "t_not_exist.mp4" source to field number 1 Then I see status message "not found" - And I see value "" in the field "HTML5 Transcript" + And I see value "" in the field "Transcript (primary)" #8 Scenario: html5 source only: check "Found" state @@ -132,7 +132,7 @@ Feature: CMS.Transcripts And I enter a "t_not_exist.mp4" source to field number 1 Then I see status message "found" - And I see value "t_not_exist" in the field "HTML5 Transcript" + And I see value "t_not_exist" in the field "Transcript (primary)" #9 Scenario: User sets youtube_id w/o server but with local subs and one html5 link w/o subs @@ -144,7 +144,7 @@ Feature: CMS.Transcripts And I enter a "test_video_name.mp4" source to field number 2 Then I see status message "found" - And I see value "t_not_exist" in the field "HTML5 Transcript" + And I see value "t_not_exist" in the field "Transcript (primary)" # Disabled 1/29/14 due to flakiness observed in master #10 @@ -160,7 +160,7 @@ Feature: CMS.Transcripts # # And I enter a "t_not_exist.mp4" source to field number 2 # Then I see status message "found" - # And I see value "t__eq_exist" in the field "HTML5 Transcript" + # And I see value "t__eq_exist" in the field "Transcript (primary)" #11 Scenario: User sets youtube_id w/o local but with server subs and one html5 link w/o transcripts w/o import action, then another one html5 link w/o transcripts @@ -338,7 +338,7 @@ Feature: CMS.Transcripts Then I see status message "uploaded_successfully" And I see button "download_to_edit" And I see button "upload_new_timed_transcripts" - And I see value "t__eq_exist" in the field "HTML5 Transcript" + And I see value "t__eq_exist" in the field "Transcript (primary)" And I enter a "http://youtu.be/t_not_exist" source to field number 2 Then I see status message "found" @@ -359,7 +359,7 @@ Feature: CMS.Transcripts And I see button "upload_new_timed_transcripts" And I upload the transcripts file "test_transcripts.srt" Then I see status message "uploaded_successfully" - And I see value "test_transcripts" in the field "HTML5 Transcript" + And I see value "test_transcripts" in the field "Transcript (primary)" And I enter a "t_not_exist.webm" source to field number 2 Then I see status message "replace" @@ -367,7 +367,7 @@ Feature: CMS.Transcripts And I see choose button "test_transcripts.mp4" number 1 And I see choose button "t_not_exist.webm" number 2 And I click transcript button "choose" number 2 - And I see value "test_transcripts|t_not_exist" in the field "HTML5 Transcript" + And I see value "test_transcripts|t_not_exist" in the field "Transcript (primary)" #21 Scenario: Work with 1 field only: Enter HTML5 source with transcripts - save - > change it to another one HTML5 source w/o transcripts - click on use existing - > change it to another one HTML5 source w/o transcripts - click on use existing @@ -378,7 +378,7 @@ Feature: CMS.Transcripts Then I see status message "found" And I see button "download_to_edit" And I see button "upload_new_timed_transcripts" - And I see value "t_not_exist" in the field "HTML5 Transcript" + And I see value "t_not_exist" in the field "Transcript (primary)" And I save changes And I edit the component @@ -387,13 +387,13 @@ Feature: CMS.Transcripts Then I see status message "use existing" And I see button "use_existing" And I click transcript button "use_existing" - And I see value "video_name_2" in the field "HTML5 Transcript" + And I see value "video_name_2" in the field "Transcript (primary)" And I enter a "video_name_3.mp4" source to field number 1 Then I see status message "use existing" And I see button "use_existing" And I click transcript button "use_existing" - And I see value "video_name_3" in the field "HTML5 Transcript" + And I see value "video_name_3" in the field "Transcript (primary)" #22 Scenario: Work with 1 field only: Enter HTML5 source with transcripts - save -> change it to another one HTML5 source w/o transcripts - click on use existing -> change it to another one HTML5 source w/o transcripts - do not click on use existing -> change it to another one HTML5 source w/o transcripts - click on use existing @@ -404,7 +404,7 @@ Feature: CMS.Transcripts Then I see status message "found" And I see button "download_to_edit" And I see button "upload_new_timed_transcripts" - And I see value "t_not_exist" in the field "HTML5 Transcript" + And I see value "t_not_exist" in the field "Transcript (primary)" And I save changes And I edit the component @@ -413,7 +413,7 @@ Feature: CMS.Transcripts Then I see status message "use existing" And I see button "use_existing" And I click transcript button "use_existing" - And I see value "video_name_2" in the field "HTML5 Transcript" + And I see value "video_name_2" in the field "Transcript (primary)" And I enter a "video_name_3.mp4" source to field number 1 Then I see status message "use existing" @@ -423,7 +423,7 @@ Feature: CMS.Transcripts Then I see status message "use existing" And I see button "use_existing" And I click transcript button "use_existing" - And I see value "video_name_4" in the field "HTML5 Transcript" + And I see value "video_name_4" in the field "Transcript (primary)" #23 Scenario: Work with 2 fields: Enter HTML5 source with transcripts - save -> change it to another one HTML5 source w/o transcripts - do not click on use existing -> add another one HTML5 source w/o transcripts - click on use existing @@ -446,7 +446,7 @@ Feature: CMS.Transcripts Then I see status message "use existing" And I see button "use_existing" And I click transcript button "use_existing" - And I see value "video_name_2|video_name_3" in the field "HTML5 Transcript" + And I see value "video_name_2|video_name_3" in the field "Transcript (primary)" #24 Uploading subtitles with different file name than file Scenario: File name and name of subs are different @@ -457,7 +457,7 @@ Feature: CMS.Transcripts And I see status message "not found" And I upload the transcripts file "test_transcripts.srt" Then I see status message "uploaded_successfully" - And I see value "video_name_1" in the field "HTML5 Transcript" + And I see value "video_name_1" in the field "Transcript (primary)" And I save changes Then when I view the video it does show the captions @@ -488,14 +488,14 @@ Feature: CMS.Transcripts And I see status message "not found" And I upload the transcripts file "test_transcripts.srt" Then I see status message "uploaded_successfully" - And I see value "video_name_1|video_name_2" in the field "HTML5 Transcript" + And I see value "video_name_1|video_name_2" in the field "Transcript (primary)" And I clear field number 1 Then I see status message "found" - And I see value "video_name_2" in the field "HTML5 Transcript" + And I see value "video_name_2" in the field "Transcript (primary)" #27 - Scenario: Upload button for single youtube id. + Scenario: Upload button for single youtube id Given I have created a Video component And I edit the component @@ -512,7 +512,7 @@ Feature: CMS.Transcripts Then I see status message "found" #28 - Scenario: Upload button for youtube id with html5 ids. + Scenario: Upload button for youtube id with html5 ids Given I have created a Video component And I edit the component @@ -528,7 +528,7 @@ Feature: CMS.Transcripts Then I see status message "uploaded_successfully" And I clear field number 1 Then I see status message "found" - And I see value "video_name_1" in the field "HTML5 Transcript" + And I see value "video_name_1" in the field "Transcript (primary)" And I save changes Then when I view the video it does show the captions @@ -544,14 +544,14 @@ Feature: CMS.Transcripts Then I see status message "not found" And I open tab "Advanced" - And I set value "t_not_exist" to the field "HTML5 Transcript" + And I set value "t_not_exist" to the field "Transcript (primary)" And I save changes Then when I view the video it does show the captions And I edit the component Then I see status message "found" - And I see value "video_name_1" in the field "HTML5 Transcript" + And I see value "video_name_1" in the field "Transcript (primary)" #30 Scenario: Check non-ascii (chinise) transcripts @@ -576,7 +576,7 @@ Feature: CMS.Transcripts Then I see status message "not found" And I open tab "Advanced" - And I set value "t_not_exist" to the field "HTML5 Transcript" + And I set value "t_not_exist" to the field "Transcript (primary)" And I open tab "Basic" Then I see status message "found" @@ -585,18 +585,20 @@ Feature: CMS.Transcripts And I edit the component Then I see status message "found" - And I see value "video_name_1" in the field "HTML5 Transcript" + And I see value "video_name_1" in the field "Transcript (primary)" #32 Scenario: After clearing Transcripts field in the Advanced tab "not found" message should be visible w/o saving - Given I have created a Video component with subtitles "t_not_exist" + Given I have created a Video component And I edit the component And I enter a "t_not_exist.mp4" source to field number 1 - Then I see status message "found" + Then I see status message "not found" + And I upload the transcripts file "chinese_transcripts.srt" + Then I see status message "uploaded_successfully" And I open tab "Advanced" - And I set value "" to the field "HTML5 Transcript" + And I set value "" to the field "Transcript (primary)" And I open tab "Basic" Then I see status message "not found" @@ -605,21 +607,24 @@ Feature: CMS.Transcripts And I edit the component Then I see status message "not found" - And I see value "" in the field "HTML5 Transcript" + And I see value "" in the field "Transcript (primary)" #33 Scenario: After clearing Transcripts field in the Advanced tab "not found" message should be visible with saving - Given I have created a Video component with subtitles "t_not_exist" + Given I have created a Video component And I edit the component And I enter a "t_not_exist.mp4" source to field number 1 - Then I see status message "found" + Then I see status message "not found" + And I upload the transcripts file "chinese_transcripts.srt" + Then I see status message "uploaded_successfully" And I save changes + Then I see "好 各位同学" text in the captions And I edit the component And I open tab "Advanced" - And I set value "" to the field "HTML5 Transcript" + And I set value "" to the field "Transcript (primary)" And I open tab "Basic" Then I see status message "not found" @@ -628,7 +633,7 @@ Feature: CMS.Transcripts And I edit the component Then I see status message "not found" - And I see value "" in the field "HTML5 Transcript" + And I see value "" in the field "Transcript (primary)" #34 Scenario: Video with existing subs - Advanced tab - change to another one subs - Basic tab - Found message - Save - see correct subs @@ -647,7 +652,7 @@ Feature: CMS.Transcripts And I edit the component And I open tab "Advanced" - And I set value "t_not_exist" to the field "HTML5 Transcript" + And I set value "t_not_exist" to the field "Transcript (primary)" And I open tab "Basic" Then I see status message "found" @@ -670,7 +675,7 @@ Feature: CMS.Transcripts And I edit the component And I open tab "Advanced" - And I revert the transcript field "HTML5 Transcript" + And I revert the transcript field "Transcript (primary)" And I save changes Then when I view the video it does not show the captions @@ -686,7 +691,7 @@ Feature: CMS.Transcripts And I see status message "not found" And I upload the transcripts file "test_transcripts.srt" Then I see status message "uploaded_successfully" - And I see value "video_name_1.1.2" in the field "HTML5 Transcript" + And I see value "video_name_1.1.2" in the field "Transcript (primary)" And I save changes Then when I view the video it does show the captions diff --git a/cms/djangoapps/contentstore/features/transcripts.py b/cms/djangoapps/contentstore/features/transcripts.py index 695c96c8b8..41903afccf 100644 --- a/cms/djangoapps/contentstore/features/transcripts.py +++ b/cms/djangoapps/contentstore/features/transcripts.py @@ -103,7 +103,7 @@ def i_do_not_see_error_message(_step): @step('I see error message "([^"]*)"$') def i_see_error_message(_step, error): - assert world.css_has_text(SELECTORS['error_bar'], ERROR_MESSAGES[error.strip()]) + assert world.css_has_text(SELECTORS['error_bar'], ERROR_MESSAGES[error]) @step('I do not see status message$') @@ -114,7 +114,7 @@ def i_do_not_see_status_message(_step): @step('I see status message "([^"]*)"$') def i_see_status_message(_step, status): assert not world.css_visible(SELECTORS['error_bar']) - assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status.strip()]) + assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status]) DOWNLOAD_BUTTON = TRANSCRIPTS_BUTTONS["download_to_edit"][0] if world.is_css_present(DOWNLOAD_BUTTON, wait_time=1) \ diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature index 07c298b0d2..1c12915e91 100644 --- a/cms/djangoapps/contentstore/features/video-editor.feature +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -1,6 +1,6 @@ @shard_3 -Feature: CMS.Video Component Editor - As a course author, I want to be able to create video components. +Feature: CMS Video Component Editor + As a course author, I want to be able to create video components Scenario: User can view Video metadata Given I have created a Video component @@ -17,14 +17,14 @@ Feature: CMS.Video Component Editor # Sauce Labs cannot delete cookies @skip_sauce - Scenario: Captions are hidden when "show captions" is false + Scenario: Captions are hidden when "transcript display" is false Given I have created a Video component with subtitles - And I have set "show transcript" to False + And I have set "transcript display" to False Then when I view the video it does not show the captions # Sauce Labs cannot delete cookies @skip_sauce - Scenario: Captions are shown when "show captions" is true + Scenario: Captions are shown when "transcript display" is true Given I have created a Video component with subtitles - And I have set "show transcript" to True + And I have set "transcript display" to True Then when I view the video it does show the captions diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index b8c74be80a..ea90993d67 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -5,7 +5,7 @@ from lettuce import world, step from terrain.steps import reload_the_page -@step('I have set "show transcript" to (.*)$') +@step('I have set "transcript display" to (.*)$') def set_show_captions(step, setting): # Prevent cookies from overriding course settings world.browser.cookies.delete('hide_captions') @@ -13,7 +13,7 @@ def set_show_captions(step, setting): world.css_click('a.edit-button') world.wait_for(lambda _driver: world.css_visible('a.save-button')) world.click_link_by_text('Advanced') - world.browser.select('Show Transcript', setting) + world.browser.select('Transcript Display', setting) world.css_click('a.save-button') @@ -42,10 +42,11 @@ def correct_video_settings(_step): ['Display Name', 'Video', False], ['Download Transcript', '', False], ['End Time', '00:00:00', False], - ['HTML5 Transcript', '', False], - ['Show Transcript', 'True', False], ['Start Time', '00:00:00', False], + ['Transcript (primary)', '', False], + ['Transcript Display', 'True', False], ['Transcript Download Allowed', 'False', False], + ['Transcript Translations', '', False], ['Video Download Allowed', 'False', False], ['Video Sources', '', False], ['Youtube ID', 'OEoXaMPEzfM', False], diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index 2ead8987e9..fc70ee3c14 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -1,6 +1,6 @@ @shard_3 -Feature: CMS.Video Component - As a course author, I want to be able to view my created videos in Studio. +Feature: CMS Video Component + As a course author, I want to be able to view my created videos in Studio # 1 # Video Alpha Features will work in Firefox only when Firefox is the active window @@ -43,38 +43,6 @@ Feature: CMS.Video Component Then the correct Youtube video is shown # 7 - Scenario: Closed captions become visible when the mouse hovers over CC button - Given I have created a Video component with subtitles - And Make sure captions are closed - Then Captions become "invisible" - And I hover over button "CC" - Then Captions become "visible" - And I hover over button "volume" - Then Captions become "invisible" - - # 8 - # Disabled 11/26 due to flakiness in master. - # Enabled back on 11/29. - Scenario: Open captions never become invisible - Given I have created a Video component with subtitles - And Make sure captions are open - Then Captions are "visible" - And I hover over button "CC" - Then Captions are "visible" - And I hover over button "volume" - Then Captions are "visible" - - # 9 - # Disabled 11/26 due to flakiness in master. - # Enabled back on 11/29. - Scenario: Closed captions are invisible when mouse doesn't hover on CC button - Given I have created a Video component with subtitles - And Make sure captions are closed - Then Captions become "invisible" - And I hover over button "volume" - Then Captions are "invisible" - - # 10 # Disabled 11/26 due to flakiness in master. # Enabled back on 11/29. Scenario: When enter key is pressed on a caption shows an outline around it @@ -84,7 +52,7 @@ Feature: CMS.Video Component Then I press "enter" button on caption line with data-index "0" And I see caption line with data-index "0" has class "focused" - # 11 + # 8 Scenario: When start end end times are specified, a range on slider is shown Given I have created a Video component with subtitles And Make sure captions are closed diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index 2dc4bccfb3..b3777aa57d 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -56,6 +56,13 @@ def i_created_a_video_with_subs_with_name(_step, sub_id): world.visit(video_url) world.wait_for_xmodule() + + # update .sub filed with proper subs name (which mimics real Studio/XML behavior) + # this is needed only for that videos which are created in acceptance tests. + _step.given('I edit the component') + world.wait_for_ajax_complete() + _step.given('I save changes') + world.disable_jquery_animations() world.wait_for_present('.is-initialized') diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index c19c4d198c..8d44b98d95 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -492,15 +492,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertContains(resp, 'i4x://edX/toy/video/video_with_end_time') self.assertContains(resp, 'i4x://edX/toy/poll_question/T1_changemind_poll_foo_2') - def test_video_module_caption_asset_path(self): - """ - This verifies that a video caption url is as we expect it to be - """ - resp = self._test_preview(Location('i4x', 'edX', 'toy', 'video', 'sample_video', None)) - self.assertEquals(resp.status_code, 200) - content = json.loads(resp.content) - self.assertIn('data-caption-asset-path="/c4x/edX/toy/asset/subs_"', content['html']) - def _test_preview(self, location): """ Preview test case. """ direct_store = modulestore('direct') diff --git a/cms/djangoapps/contentstore/tests/test_transcripts_utils.py b/cms/djangoapps/contentstore/tests/test_transcripts_utils.py index a34927e3ef..c0c179abb8 100644 --- a/cms/djangoapps/contentstore/tests/test_transcripts_utils.py +++ b/cms/djangoapps/contentstore/tests/test_transcripts_utils.py @@ -3,11 +3,13 @@ import unittest from uuid import uuid4 import copy import textwrap +from mock import patch, Mock from pymongo import MongoClient from django.test.utils import override_settings from django.conf import settings +from django.utils import translation from nose.plugins.skip import SkipTest @@ -16,7 +18,7 @@ from xmodule.contentstore.content import StaticContent from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.exceptions import NotFoundError from xmodule.contentstore.django import contentstore, _CONTENTSTORE -from contentstore import transcripts_utils +from xmodule.video_module import transcripts_utils from contentstore.tests.modulestore_config import TEST_MODULESTORE TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) @@ -188,20 +190,29 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase): def test_success_downloading_subs(self): - # Disabled 11/14/13 - # This test is flakey because it performs an HTTP request on an external service - # Re-enable when `requests.get` is patched using `mock.patch` - raise SkipTest - + response = textwrap.dedent(""" + + + Test text 1. + Test text 2. + Test text 3. + + """) good_youtube_subs = { - 0.5: 'JMD_ifUUfsU', - 1.0: 'hI10vDNYz4M', - 2.0: 'AKqURZnYqpk' + 0.5: 'good_id_1', + 1.0: 'good_id_2', + 2.0: 'good_id_3' } self.clear_subs_content(good_youtube_subs) - # Check transcripts_utils.GetTranscriptsFromYouTubeException not thrown - transcripts_utils.download_youtube_subs(good_youtube_subs, self.course) + with patch('xmodule.video_module.transcripts_utils.requests.get') as mock_get: + mock_get.return_value = Mock(status_code=200, text=response, content=response) + # Check transcripts_utils.GetTranscriptsFromYouTubeException not thrown + transcripts_utils.download_youtube_subs(good_youtube_subs, self.course, settings) + + mock_get.assert_any_call('http://video.google.com/timedtext', params={'lang': 'en', 'v': 'good_id_1'}) + mock_get.assert_any_call('http://video.google.com/timedtext', params={'lang': 'en', 'v': 'good_id_2'}) + mock_get.assert_any_call('http://video.google.com/timedtext', params={'lang': 'en', 'v': 'good_id_3'}) # Check assets status after importing subtitles. for subs_id in good_youtube_subs.values(): @@ -226,12 +237,10 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase): self.assertEqual(html5_ids[2], 'baz.1.4') self.assertEqual(html5_ids[3], 'foo') - def test_fail_downloading_subs(self): + @patch('xmodule.video_module.transcripts_utils.requests.get') + def test_fail_downloading_subs(self, mock_get): - # Disabled 11/14/13 - # This test is flakey because it performs an HTTP request on an external service - # Re-enable when `requests.get` is patched using `mock.patch` - raise SkipTest + mock_get.return_value = Mock(status_code=404, text='Error 404') bad_youtube_subs = { 0.5: 'BAD_YOUTUBE_ID1', @@ -239,9 +248,8 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase): 2.0: 'BAD_YOUTUBE_ID3' } self.clear_subs_content(bad_youtube_subs) - with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException): - transcripts_utils.download_youtube_subs(bad_youtube_subs, self.course) + transcripts_utils.download_youtube_subs(bad_youtube_subs, self.course, settings) # Check assets status after importing subtitles. for subs_id in bad_youtube_subs.values(): @@ -267,7 +275,7 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase): self.clear_subs_content(good_youtube_subs) # Check transcripts_utils.GetTranscriptsFromYouTubeException not thrown - transcripts_utils.download_youtube_subs(good_youtube_subs, self.course) + transcripts_utils.download_youtube_subs(good_youtube_subs, self.course, settings) # Check assets status after importing subtitles. for subs_id in good_youtube_subs.values(): @@ -438,3 +446,43 @@ class TestGenerateSrtFromSjson(TestDownloadYoutubeSubs): } srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 1) self.assertFalse(srt_subs) + + +class TestYoutubeTranscripts(unittest.TestCase): + """ + Tests for checking right datastructure returning when using youtube api. + """ + @patch('xmodule.video_module.transcripts_utils.requests.get') + def test_youtube_bad_status_code(self, mock_get): + mock_get.return_value = Mock(status_code=404, text='test') + youtube_id = 'bad_youtube_id' + with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException): + transcripts_utils.get_transcripts_from_youtube(youtube_id, settings, translation) + + @patch('xmodule.video_module.transcripts_utils.requests.get') + def test_youtube_empty_text(self, mock_get): + mock_get.return_value = Mock(status_code=200, text='') + youtube_id = 'bad_youtube_id' + with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException): + transcripts_utils.get_transcripts_from_youtube(youtube_id, settings, translation) + + def test_youtube_good_result(self): + response = textwrap.dedent(""" + + + Test text 1. + Test text 2. + Test text 3. + + """) + expected_transcripts = { + 'start': [270, 2720, 5430], + 'end': [2720, 2720, 7160], + 'text': ['Test text 1.', 'Test text 2.', 'Test text 3.'] + } + youtube_id = 'good_youtube_id' + with patch('xmodule.video_module.transcripts_utils.requests.get') as mock_get: + mock_get.return_value = Mock(status_code=200, text=response, content=response) + transcripts = transcripts_utils.get_transcripts_from_youtube(youtube_id, settings, translation) + self.assertEqual(transcripts, expected_transcripts) + mock_get.assert_called_with('http://video.google.com/timedtext', params={'lang': 'en', 'v': 'good_youtube_id'}) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 80548a27a7..4945a0e6f2 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -24,12 +24,11 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationErr from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.locator import BlockUsageLocator from xmodule.modulestore import Location +from xmodule.video_module import manage_video_subtitles_save from util.json_request import expect_json, JsonResponse from util.string_utils import str_to_bool -from ..transcripts_utils import manage_video_subtitles_save - from ..utils import get_modulestore from .access import has_course_access @@ -251,6 +250,8 @@ def _save_item(request, usage_loc, item_location, data=None, children=None, meta log.error("Can't find item by location.") return JsonResponse({"error": "Can't find item by location: " + str(item_location)}, 404) + old_metadata = own_metadata(existing_item) + if publish: if publish == 'make_private': _xmodule_recurse(existing_item, lambda i: modulestore().unpublish(i.location)) @@ -299,7 +300,7 @@ def _save_item(request, usage_loc, item_location, data=None, children=None, meta field.write_to(existing_item, value) if existing_item.category == 'video': - manage_video_subtitles_save(existing_item, existing_item, request.user) + manage_video_subtitles_save(existing_item, request.user, old_metadata, generate_translation=True) # commit to datastore store.update_item(existing_item, request.user.id) diff --git a/cms/djangoapps/contentstore/views/tests/test_transcripts.py b/cms/djangoapps/contentstore/views/tests/test_transcripts.py index f92e8e7f21..9722bbfa06 100644 --- a/cms/djangoapps/contentstore/views/tests/test_transcripts.py +++ b/cms/djangoapps/contentstore/views/tests/test_transcripts.py @@ -12,7 +12,7 @@ from django.core.urlresolvers import reverse from django.test.utils import override_settings from django.conf import settings -from contentstore import transcripts_utils +from xmodule.video_module import transcripts_utils from contentstore.tests.utils import CourseTestCase from cache_toolbox.core import del_cached_content from xmodule.modulestore.django import modulestore diff --git a/cms/djangoapps/contentstore/views/transcripts_ajax.py b/cms/djangoapps/contentstore/views/transcripts_ajax.py index 01535f6021..d30e2a29c4 100644 --- a/cms/djangoapps/contentstore/views/transcripts_ajax.py +++ b/cms/djangoapps/contentstore/views/transcripts_ajax.py @@ -26,12 +26,11 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationErr from util.json_request import JsonResponse from xmodule.modulestore.locator import BlockUsageLocator -from ..transcripts_utils import ( +from xmodule.video_module.transcripts_utils import ( generate_subs_from_source, generate_srt_from_sjson, remove_subs_from_store, download_youtube_subs, get_transcripts_from_youtube, copy_or_rename_transcript, - save_module, manage_video_subtitles_save, TranscriptsGenerationException, GetTranscriptsFromYouTubeException, @@ -136,7 +135,7 @@ def upload_transcripts(request): return error_response(response, "Can't find transcripts in storage for {}".format(sub_attr)) item.sub = selected_name # write one of new subtitles names to item.sub attribute. - save_module(item, request.user) + item.save_with_metadata(request.user) response['subs'] = item.sub response['status'] = 'Success' else: @@ -272,7 +271,11 @@ def check_transcripts(request): #check youtube local and server transcripts for equality if transcripts_presence['youtube_server'] and transcripts_presence['youtube_local']: try: - youtube_server_subs = get_transcripts_from_youtube(youtube_id) + youtube_server_subs = get_transcripts_from_youtube( + youtube_id, + settings, + item.runtime.service(item, "i18n") + ) if json.loads(local_transcripts) == youtube_server_subs: # check transcripts for equality transcripts_presence['youtube_diff'] = False except GetTranscriptsFromYouTubeException: @@ -389,7 +392,7 @@ def choose_transcripts(request): if item.sub != html5_id: # update sub value item.sub = html5_id - save_module(item, request.user) + item.save_with_metadata(request.user) response = {'status': 'Success', 'subs': item.sub} return JsonResponse(response) @@ -415,12 +418,12 @@ def replace_transcripts(request): return error_response(response, 'YouTube id {} is not presented in request data.'.format(youtube_id)) try: - download_youtube_subs({1.0: youtube_id}, item) + download_youtube_subs({1.0: youtube_id}, item, settings) except GetTranscriptsFromYouTubeException as e: return error_response(response, e.message) item.sub = youtube_id - save_module(item, request.user) + item.save_with_metadata(request.user) response = {'status': 'Success', 'subs': item.sub} return JsonResponse(response) @@ -519,10 +522,10 @@ def save_transcripts(request): for metadata_key, value in metadata.items(): setattr(item, metadata_key, value) - save_module(item, request.user) # item becomes updated with new values + item.save_with_metadata(request.user) # item becomes updated with new values if new_sub: - manage_video_subtitles_save(None, item, request.user) + manage_video_subtitles_save(item, request.user) else: # If `new_sub` is empty, it means that user explicitly does not want to use # transcripts for current video ids and we remove all transcripts from storage. diff --git a/cms/envs/common.py b/cms/envs/common.py index 23b7de10a5..8b61901327 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -26,7 +26,9 @@ Longer TODO: import sys import lms.envs.common -from lms.envs.common import USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL, DOC_STORE_CONFIG, enable_microsites +from lms.envs.common import ( + USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL, DOC_STORE_CONFIG, enable_microsites, ALL_LANGUAGES +) from path import path from lms.lib.xblock.mixin import LmsBlockMixin diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index 5f4e9d1063..5fc6486698 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -164,6 +164,37 @@ div.video { } } + %video-button { + @include transition(none); + display: block; + font-weight: 800; + line-height: 46px; + margin: 0; + padding: 0 0 0 15px; + text-indent: -9999px; + -webkit-font-smoothing: antialiased; + box-shadow: 1px 0 0 #555, inset 1px 0 0 #555; + color: #fff; + cursor: pointer; + border-width: 0 1px; + border-style: solid; + border-color: #000; + + &:hover { + background-color: #444; + color: #fff; + text-decoration: none; + outline: 0; + } + + &:active, + &:focus { + color: #fff; + background-color: #444; + text-decoration: none; + } + } + div.slider { @include clearfix(); @include transform(scaleY(0.5) translate3d(0, 50%, 0)); @@ -230,48 +261,33 @@ div.video { margin-bottom: 0; a { - border-bottom: none; - border-right: 1px solid #000; + @extend %video-button; + background-image: url('../images/vcr.png'); + background-position: 15px 15px ; + background-repeat: no-repeat; + border-left: none; box-shadow: 1px 0 0 #555; - cursor: pointer; - display: block; - line-height: 46px; padding: 0 lh(.75); - text-indent: -9999px; width: 14px; - background: url('../images/vcr.png') 15px 15px no-repeat; &:focus { position: relative; z-index: 10000; outline: #fff dotted thin; outline-offset: -2px; - background: #333; - } - - &:hover { - outline: 0; } &:empty { height: 46px; - background: url('../images/vcr.png') 15px 15px no-repeat; + background-position: 15px 15px; } &.play { background-position: 17px -114px; - - &:hover { - background-color: #444; - } } &.pause { background-position: 16px -50px; - - &:hover { - background-color: #444; - } } } @@ -301,16 +317,12 @@ div.video { } } - div.speeds { + .menu-container { float: left; position: relative; &.open { - & > a { - background: url('../images/open-arrow.png') 10px center no-repeat; - } - - ol.video_speeds { + .menu { display: block; opacity: 1; padding: 0; @@ -319,22 +331,77 @@ div.video { } } - & > a { - @include clearfix(); + .menu { @include transition(none); - background: url('../images/closed-arrow.png') 10px center no-repeat; - border-left: 1px solid #000; - border-right: 1px solid #000; - box-shadow: 1px 0 0 #555, inset 1px 0 0 #555; - color: #fff; - cursor: pointer; - display: block; - line-height: 46px; //height of play pause buttons - margin-right: 0; - padding-left: 15px; - position: relative; - -webkit-font-smoothing: antialiased; + box-shadow: inset 1px 0 0 #555, 0 1px 0 #444; + background-color: #444; + border: 1px solid #000; + bottom: 46px; + display: none; + opacity: 0; + position: absolute; + z-index: 10; + + li { + box-shadow: 0 1px 0 #555; + border-bottom: 1px solid #000; + color: #fff; + cursor: pointer; + + a { + border: 0; + color: #fff; + display: block; + padding: lh(.5); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + background-color: #666; + color: #aaa; + outline-offset: -4px; + } + } + + &.active{ + a { + font-weight: bold; + } + } + + &:last-child { + box-shadow: none; + border-bottom: 0; + margin-top: 0; + } + } + } + } + + div.speeds { + &.open { + & > a { + background-image: url('../images/open-arrow.png'); + } + } + + .menu{ + width: 131px; + + @media (max-width: 1024px) { + width: 101px; + } + } + + & > a { + @extend %video-button; + @include clearfix(); + background-image: url('../images/closed-arrow.png'); + background-position: 10px center; + background-repeat: no-repeat; min-width: 116px; + text-indent: 0; @media (max-width: 1024px) { min-width: 0; @@ -342,26 +409,6 @@ div.video { } h3 { - display: block; - - @media (max-width: 1024px) { - display: none; - } - } - - &:hover { - outline: 0; - opacity: 1; - background-color: #444; - } - - &:active { - opacity: 1; - background-color: #444; - } - - h3 { - color: #999; float: left; font-size: em(14); font-weight: normal; @@ -369,6 +416,11 @@ div.video { padding: 0 lh(.25) 0 lh(.5); line-height: 46px; text-transform: uppercase; + color: #999; + + @media (max-width: 1024px) { + display: none; + } } p.active { @@ -385,55 +437,6 @@ div.video { color: #fff; } } - - // fix for now - ol.video_speeds { - @include transition(none); - box-shadow: inset 1px 0 0 #555, 0 4px 0 #444; - background-color: #444; - border: 1px solid #000; - bottom: 46px; - display: none; - opacity: 0; - position: absolute; - width: 131px; - - @media (max-width: 1024px) { - width: 101px; - } - - z-index: 10; - - li { - box-shadow: 0 1px 0 #555; - border-bottom: 1px solid #000; - color: #fff; - cursor: pointer; - - a { - border: 0; - color: #fff; - display: block; - padding: lh(.5); - - &:hover { - background-color: #666; - color: #aaa; - outline-offset: -4px; - } - } - - &.active { - font-weight: bold; - } - - &:last-child { - box-shadow: none; - border-bottom: 0; - margin-top: 0; - } - } - } } div.volume { @@ -454,29 +457,14 @@ div.video { } & > a { + @extend %video-button; @include clearfix(); - @include transition(none); background-image: url('../images/volume.png'); background-position: 10px center; background-repeat: no-repeat; - border-right: 1px solid #000; - box-shadow: 1px 0 0 #555, inset 1px 0 0 #555; - color: #fff; - cursor: pointer; - display: block; - height: 46px; - margin-right: 0; - padding-left: 15px; - position: relative; - -webkit-font-smoothing: antialiased; + border-left: none; width: 30px; - - &:hover, &:active { - background-color: #444; - color: #fff; - text-decoration: none; - outline: 0; - } + height: 46px; } .volume-slider-container { @@ -523,49 +511,24 @@ div.video { } a.add-fullscreen { - @include transition(none); + @extend %video-button; background: url(../images/fullscreen.png) center no-repeat; - border-right: 1px solid #000; - box-shadow: 1px 0 0 #555, inset 1px 0 0 #555; - color: #797979; - display: block; + border-left: none; float: left; - line-height: 46px; //height of play pause buttons - margin-left: 0; - padding: 0 lh(.5); - text-indent: -9999px; + padding: 0 11px; width: 30px; - - &:hover, &:active { - background-color: #444; - color: #fff; - text-decoration: none; - outline: 0; - } } a.quality_control { - @include transition(none); + @extend %video-button; background: url(../images/hd.png) center no-repeat; - border-right: 1px solid #000; - box-shadow: 1px 0 0 #555, inset 1px 0 0 #555; - color: #797979; + border-left: none; display: none; float: left; - line-height: 46px; //height of play pause buttons - margin-left: 0; - padding: 0 lh(.5); - text-indent: -9999px; + padding: 0 11px; width: 30px; - &:hover { - background-color: #444; - color: #fff; - text-decoration: none; - outline: 0; - } - &.active { background-color: #F44; color: #0ff; @@ -574,33 +537,26 @@ div.video { } } + div.lang { + & > a.hide-subtitles { + @extend %video-button; + @include transition(none); + box-shadow: inset 1px 0 0 #555; + background: url('../images/cc.png') center no-repeat; + border-left: none; + border-right: none; + padding: 0 11px; + width: 30px; - a.hide-subtitles { - @include transition(none); - background: url('../images/cc.png') center no-repeat; - float: left; - font-weight: 800; - line-height: 46px; //height of play pause buttons - margin-left: 0; - opacity: 1; - padding: 0 lh(.5); - position: relative; - text-indent: -9999px; - -webkit-font-smoothing: antialiased; - width: 30px; - - &:hover { - background-color: #444; - color: #fff; - text-decoration: none; - outline: 0; + &.off { + opacity: 0.7; + } } - &.off { - opacity: 0.7; + .menu.langs-list { + right: -1px; + width: 150px; } - - color: #797979; } } } diff --git a/common/lib/xmodule/xmodule/js/fixtures/video.html b/common/lib/xmodule/xmodule/js/fixtures/video.html index 89b4e360c6..c08b544bc1 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video.html @@ -11,7 +11,10 @@ data-start="" data-end="" data-saved-video-position="0" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-autoplay="False" data-yt-test-timeout="1500" data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" @@ -51,7 +54,9 @@ Fill Browser HD off - Captions + diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_all.html b/common/lib/xmodule/xmodule/js/fixtures/video_all.html index 982aca0232..7a19c951a6 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_all.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_all.html @@ -10,7 +10,10 @@ data-start="" data-end="" data-saved-video-position="0" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-sub="Z5KLxerq05Y" data-mp4-source="xmodule/include/fixtures/test.mp4" data-webm-source="xmodule/include/fixtures/test.webm" @@ -54,7 +57,9 @@ Fill Browser HD off - Captions + diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_html5.html b/common/lib/xmodule/xmodule/js/fixtures/video_html5.html index c3c61ba398..c330a0fb8f 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_html5.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_html5.html @@ -10,7 +10,10 @@ data-start="" data-end="" data-saved-video-position="0" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-sub="Z5KLxerq05Y" data-mp4-source="xmodule/include/fixtures/test.mp4" data-webm-source="xmodule/include/fixtures/test.webm" diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html b/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html index ee98a3992e..1a28833f56 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html @@ -11,7 +11,10 @@ data-start="" data-end="" data-saved-video-position="0" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-autoplay="False" data-yt-test-timeout="1500" data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html b/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html index 33d304688b..df512bd7ab 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html @@ -11,7 +11,10 @@ data-start="" data-end="" data-saved-video-position="0" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-autoplay="False" data-yt-test-timeout="1500" data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" @@ -51,7 +54,9 @@ Fill Browser HD off - Captions + @@ -73,9 +78,13 @@ class="video" data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM" data-show-captions="true" + data-speed="1.0" data-start="" data-end="" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-autoplay="False" data-yt-test-timeout="1500" data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" @@ -112,7 +121,9 @@ Fill Browser HD - Captions + @@ -132,9 +143,13 @@ class="video" data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM" data-show-captions="true" + data-speed="1.0" data-start="" data-end="" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-autoplay="False" data-yt-test-timeout="1500" data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" @@ -171,7 +186,9 @@ Fill Browser HD - Captions + diff --git a/common/lib/xmodule/xmodule/js/spec/helper.js b/common/lib/xmodule/xmodule/js/spec/helper.js index 59fe9af25e..9847f4f27b 100644 --- a/common/lib/xmodule/xmodule/js/spec/helper.js +++ b/common/lib/xmodule/xmodule/js/spec/helper.js @@ -1,6 +1,4 @@ (function ($, undefined) { - var oldAjaxWithPrefix = $.ajaxWithPrefix; - // Stub YouTube API. window.YT = { Player: function () { @@ -63,42 +61,6 @@ ] }; - // For our purposes, we need to make sure that the function - // $.ajaxWithPrefix does not fail when during tests a captions file is - // requested. It is originally defined in file: - // - // common/static/coffee/src/ajax_prefix.js - // - // We will replace it with a function that does: - // - // 1.) Return a hard coded captions object if the file name contains - // 'Z5KLxerq05Y'. - // 2.) Behaves the same a as the original function in all other cases. - $.ajaxWithPrefix = function (url, settings) { - var data, success; - - if (!settings) { - settings = url; - url = settings.url; - success = settings.success; - data = settings.data; - } - - if ( - url.match(/Z5KLxerq05Y/g) || - url.match(/7tqY6eQzVhE/g) || - url.match(/cogebirgzzM/g) - ) { - if ($.isFunction(success)) { - return success(jasmine.stubbedCaption); - } else if ($.isFunction(data)) { - return data(jasmine.stubbedCaption); - } - } else { - return oldAjaxWithPrefix.apply(this, arguments); - } - }; - // Time waitsFor() should wait for before failing a test. window.WAIT_TIMEOUT = 5000; @@ -145,13 +107,16 @@ jasmine.stubbedHtml5Speeds = ['0.75', '1.0', '1.25', '1.50']; jasmine.stubRequests = function () { - return spyOn($, 'ajax').andCallFake(function (settings) { - var match, status, callCallback; + var spy = $.ajax; - if ( - match = settings.url - .match(/youtube\.com\/.+\/videos\/(.+)\?v=2&alt=jsonc/) - ) { + if (!($.ajax.isSpy)) { + spy = spyOn($, 'ajax'); + } + return spy.andCallFake(function (settings) { + var match = settings.url + .match(/youtube\.com\/.+\/videos\/(.+)\?v=2&alt=jsonc/), + status, callCallback; + if (match) { status = match[1].split('_'); if (status && status[0] === 'status') { callCallback = function (callback) { @@ -177,11 +142,10 @@ } }; } - } else if ( - match = settings.url - .match(/static(\/.*)?\/subs\/(.+)\.srt\.sjson/) - ) { + } else if (settings.url == '/transcript/translation') { return settings.success(jasmine.stubbedCaption); + } else if (settings.url == '/transcript/available_translations') { + return settings.success(['uk', 'de']); } else if (settings.url.match(/.+\/problem_get$/)) { return settings.success({ html: readFixtures('problem_content.html') @@ -265,6 +229,7 @@ .data(params); } + jasmine.stubRequests(); state = new Video('#example'); state.resizer = (function () { diff --git a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js index 995c89627e..b0cf54bb97 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js @@ -181,31 +181,6 @@ }); }); - describe('youtubeId', function () { - beforeEach(function () { - loadFixtures('video.html'); - $.cookie.andReturn('1.0'); - state = new Video('#example'); - }); - - describe('with speed', function () { - it('return the video id for given speed', function () { - expect(state.youtubeId('0.50')) - .toEqual('7tqY6eQzVhE'); - expect(state.youtubeId('1.0')) - .toEqual('cogebirgzzM'); - expect(state.youtubeId('1.50')) - .toEqual('abcdefghijkl'); - }); - }); - - describe('without speed', function () { - it('return the video id for current speed', function () { - expect(state.youtubeId()).toEqual('abcdefghijkl'); - }); - }); - }); - describe('YouTube video in FireFox will cue first', function () { var oldUserAgent; @@ -368,84 +343,6 @@ }); }); - describe('setSpeed', function () { - - describe('YT', function () { - beforeEach(function () { - loadFixtures('video.html'); - state = new Video('#example'); - }); - - it('check mapping', function () { - var map = { - '0.75': '0.50', - '1.25': '1.50' - }; - - $.each(map, function(key, expected) { - state.setSpeed(key, true); - expect(state.speed).toBe(expected); - }); - }); - }); - describe('HTML5', function () { - beforeEach(function () { - loadFixtures('video_html5.html'); - state = new Video('#example'); - }); - - describe('when new speed is available', function () { - beforeEach(function () { - state.setSpeed('0.75', true); - }); - - it('set new speed', function () { - expect(state.speed).toEqual('0.75'); - }); - - it('save setting for new speed', function () { - - expect(state.storage.getItem('general_speed')).toBe('0.75'); - expect(state.storage.getItem('speed', true)).toBe('0.75'); - }); - }); - - describe('when new speed is not available', function () { - beforeEach(function () { - state.setSpeed('1.75'); - }); - - it('set speed to 1.0x', function () { - expect(state.speed).toEqual('1.0'); - }); - }); - - it('check mapping', function () { - var map = { - '0.25': '0.75', - '0.50': '0.75', - '2.0': '1.50' - }; - - $.each(map, function(key, expected) { - state.setSpeed(key, true); - expect(state.speed).toBe(expected); - }); - }); - }); - }); - - describe('getDuration', function () { - beforeEach(function () { - loadFixtures('video.html'); - state = new Video('#example'); - }); - - it('return duration for current video', function () { - expect(state.getDuration()).toEqual(400); - }); - }); - describe('log', function () { beforeEach(function () { loadFixtures('video_html5.html'); diff --git a/common/lib/xmodule/xmodule/js/spec/video/initialize_spec.js b/common/lib/xmodule/xmodule/js/spec/video/initialize_spec.js index 6177d4303c..020de1f384 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/initialize_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/initialize_spec.js @@ -6,8 +6,14 @@ require( ['video/01_initialize.js'], function (Initialize) { describe('Initialize', function () { + var state = {}; + + afterEach(function () { + state = {}; + }); + describe('saveState function', function () { - var state, videoPlayerCurrentTime, newCurrentTime, speed; + var videoPlayerCurrentTime, newCurrentTime, speed; // We make sure that `currentTime` is a float. We need to test // that Math.round() is called. @@ -40,10 +46,6 @@ function (Initialize) { spyOn(Time, 'formatFull').andCallThrough(); }); - afterEach(function () { - state = undefined; - }); - it('data is not an object, async is true', function () { itSpec({ asyncVal: true, @@ -161,8 +163,211 @@ function (Initialize) { }); } }); - }); + describe('getCurrentLanguage', function () { + var msg; + + beforeEach(function () { + state.config = {}; + state.config.transcriptLanguages = { + 'de': 'German', + 'en': 'English', + 'uk': 'Ukrainian', + }; + }); + + it ('returns current language', function () { + var expected; + + state.lang = 'de'; + expected = Initialize.prototype.getCurrentLanguage.call(state); + expect(expected).toBe('de'); + }); + + msg = 'returns `en`, if language isn\'t available for the video'; + it (msg, function () { + var expected; + + state.lang = 'zh'; + expected = Initialize.prototype.getCurrentLanguage.call(state); + expect(expected).toBe('en'); + }); + + msg = 'returns any available language, if current and `en` ' + + 'languages aren\'t available for the video'; + it (msg, function () { + var expected; + + state.lang = 'zh'; + state.config.transcriptLanguages = { + 'de': 'German', + 'uk': 'Ukrainian', + }; + expected = Initialize.prototype.getCurrentLanguage.call(state); + expect(expected).toBe('uk'); + }); + + it ('returns `null`, if transcript unavailable', function () { + var expected; + + state.lang = 'zh'; + state.config.transcriptLanguages = {}; + expected = Initialize.prototype.getCurrentLanguage.call(state); + expect(expected).toBeNull(); + }); + }); + + describe('getDuration', function () { + beforeEach(function () { + state = { + speed: '1.50', + metadata: { + 'testId': { + duration: 400 + }, + 'videoId': { + duration: 100 + } + }, + videos: { + '1.0': 'testId', + '1.50': 'videoId' + }, + youtubeId: Initialize.prototype.youtubeId + }; + }); + + it('returns duration for current video', function () { + var expected = Initialize.prototype.getDuration.call(state); + + expect(expected).toEqual(100); + }); + + var msg = 'returns duration for the 1.0 speed as a fallback'; + it(msg, function () { + var expected; + + state.speed = '2.0'; + expected = Initialize.prototype.getDuration.call(state); + + expect(expected).toEqual(400); + }); + }); + + describe('youtubeId', function () { + beforeEach(function () { + state = { + speed: '1.50', + videos: { + '0.50': '7tqY6eQzVhE', + '1.0': 'cogebirgzzM', + '1.50': 'abcdefghijkl' + } + }; + }); + + describe('with speed', function () { + it('return the video id for given speed', function () { + $.each(state.videos, function(speed, videoId) { + var expected = Initialize.prototype.youtubeId.call( + state, speed + ); + + expect(videoId).toBe(expected); + }); + }); + }); + + describe('without speed', function () { + it('return the video id for current speed', function () { + var expected = Initialize.prototype.youtubeId.call(state); + + expect(expected).toEqual('abcdefghijkl'); + }); + }); + + describe('speed is absent in the list of video speeds', function () { + it('return the video id for 1.0x speed', function () { + var expected = Initialize.prototype.youtubeId.call(state, '0.0'); + + expect(expected).toEqual('cogebirgzzM'); + }); + }); + }); + + describe('setSpeed', function () { + describe('YT', function () { + beforeEach(function () { + state = { + speeds: ['0.25', '0.50', '1.0', '1.50', '2.0'], + storage: jasmine.createSpyObj('storage', ['setItem']) + }; + }); + + it('check mapping', function () { + var map = { + '0.75': '0.50', + '1.25': '1.50' + }; + + $.each(map, function(key, expected) { + Initialize.prototype.setSpeed.call(state, key); + expect(state.speed).toBe(expected); + }); + }); + }); + + describe('HTML5', function () { + beforeEach(function () { + state = { + speeds: ['0.75', '1.0', '1.25', '1.50'], + storage: jasmine.createSpyObj('storage', ['setItem']) + }; + }); + + describe('when new speed is available', function () { + beforeEach(function () { + Initialize.prototype.setSpeed.call(state, '0.75', true); + }); + + it('set new speed', function () { + expect(state.speed).toEqual('0.75'); + }); + + it('save setting for new speed', function () { + expect(state.storage.setItem.calls[0].args) + .toEqual(['speed', '0.75', true]); + + expect(state.storage.setItem.calls[1].args) + .toEqual(['general_speed', '0.75']); + }); + }); + + describe('when new speed is not available', function () { + beforeEach(function () { + Initialize.prototype.setSpeed.call(state, '1.75'); + }); + + it('set speed to 1.0x', function () { + expect(state.speed).toEqual('1.0'); + }); + }); + + it('check mapping', function () { + var map = { + '0.25': '0.75', + '0.50': '0.75', + '2.0': '1.50' + }; + + $.each(map, function(key, expected) { + Initialize.prototype.setSpeed.call(state, key, true); + expect(state.speed).toBe(expected); + }); + }); + }); + }); + }); }); }(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js index 6ad591d74e..a9e5236c93 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js @@ -9,6 +9,7 @@ state = jasmine.initializePlayer(); videoControl = state.videoControl; + $.fn.scrollTo.reset(); }); afterEach(function () { @@ -28,12 +29,7 @@ describe('always', function () { beforeEach(function () { spyOn($, 'ajaxWithPrefix').andCallThrough(); - state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); }); it('create the caption element', function () { @@ -64,8 +60,12 @@ runs(function () { expect($.ajaxWithPrefix).toHaveBeenCalledWith({ - url: state.videoCaption.captionURL(), + url: '/transcript/translation', notifyOnError: false, + data: { + videoId: 'Z5KLxerq05Y', + language: 'en' + }, success: jasmine.any(Function), error: jasmine.any(Function) }); @@ -100,23 +100,98 @@ expect($('.subtitles')).toHandleWith( 'DOMMouseScroll', state.videoCaption.onMovement ); + }); + + it('bind the scroll', function () { + expect($('.subtitles')) + .toHandleWith('scroll', state.videoControl.showControls); + }); + + }); + + describe('renderLanguageMenu', function () { + describe('is rendered', function () { + it('if languages more than 1', function () { + state = jasmine.initializePlayer(); + var transcripts = state.config.transcriptLanguages, + langCodes = _.keys(transcripts), + langLabels = _.values(transcripts); + + expect($('.langs-list')).toExist(); + expect($('.langs-list')).toHandle('click'); + + + $('.langs-list li').each(function(index) { + var code = $(this).data('lang-code'), + link = $(this).find('a'), + label = link.text(); + + expect(code).toBeInArray(langCodes); + expect(label).toBeInArray(langLabels); + }); + }); + + it('when clicking on link with new language', function () { + state = jasmine.initializePlayer(); + var Caption = state.videoCaption, + link = $('.langs-list li[data-lang-code="de"] a'); + + spyOn(Caption, 'fetchCaption'); + spyOn(state.storage, 'setItem'); + + state.lang = 'en'; + link.trigger('click'); + + expect(Caption.fetchCaption).toHaveBeenCalled(); + expect(state.lang).toBe('de'); + expect(state.storage.setItem) + .toHaveBeenCalledWith('language', 'de'); + expect($('.langs-list li.active').length).toBe(1); + }); + + it('when clicking on link with current language', function () { + state = jasmine.initializePlayer(); + var Caption = state.videoCaption, + link = $('.langs-list li[data-lang-code="en"] a'); + + spyOn(Caption, 'fetchCaption'); + spyOn(state.storage, 'setItem'); + + state.lang = 'en'; + link.trigger('click'); + + expect(Caption.fetchCaption).not.toHaveBeenCalled(); + expect(state.lang).toBe('en'); + expect(state.storage.setItem) + .not.toHaveBeenCalledWith('language', 'en'); + expect($('.langs-list li.active').length).toBe(1); + }); + + it('open the language toggle on hover', function () { + state = jasmine.initializePlayer(); + $('.lang').mouseenter(); + expect($('.lang')).toHaveClass('open'); + $('.lang').mouseleave(); + expect($('.lang')).not.toHaveClass('open'); + }); }); - it('bind the scroll', function () { - expect($('.subtitles')) - .toHandleWith('scroll', state.videoCaption.autoShowCaptions); - expect($('.subtitles')) - .toHandleWith('scroll', videoControl.showControls); + describe('is not rendered', function () { + it('if just 1 language', function () { + state = jasmine.initializePlayer(null, { + 'transcriptLanguages': {"en": "English"} + }); + + expect($('.langs-list')).not.toExist(); + expect($('.lang')).not.toHandle('mouseenter'); + expect($('.lang')).not.toHandle('mouseleave'); + }); }); }); describe('when on a non touch-based device', function () { beforeEach(function () { state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); }); it('render the caption', function () { @@ -142,35 +217,46 @@ .toBe(true); }); + it('bind all the caption link', function () { + var handlerList = ['captionMouseOverOut', 'captionClick', + 'captionMouseDown', 'captionFocus', 'captionBlur', + 'captionKeyDown' + ]; + + $.each(handlerList, function(index, handler) { + spyOn(state.videoCaption, handler); + }); $('.subtitles li[data-index]').each( function (index, link) { - expect($(link)).toHandleWith( - 'mouseover', state.videoCaption.captionMouseOverOut - ); - expect($(link)).toHandleWith( - 'mouseout', state.videoCaption.captionMouseOverOut - ); - expect($(link)).toHandleWith( - 'mousedown', state.videoCaption.captionMouseDown - ); - expect($(link)).toHandleWith( - 'click', state.videoCaption.captionClick - ); - expect($(link)).toHandleWith( - 'focus', state.videoCaption.captionFocus - ); - expect($(link)).toHandleWith( - 'blur', state.videoCaption.captionBlur - ); - expect($(link)).toHandleWith( - 'keydown', state.videoCaption.captionKeyDown - ); + + $(link).trigger('mouseover'); + expect(state.videoCaption.captionMouseOverOut).toHaveBeenCalled(); + + state.videoCaption.captionMouseOverOut.reset(); + $(link).trigger('mouseout'); + expect(state.videoCaption.captionMouseOverOut).toHaveBeenCalled(); + + $(this).click(); + expect(state.videoCaption.captionClick).toHaveBeenCalled(); + + $(this).trigger('mousedown'); + expect(state.videoCaption.captionMouseDown).toHaveBeenCalled(); + + $(this).trigger('focus'); + expect(state.videoCaption.captionFocus).toHaveBeenCalled(); + + $(this).trigger('blur'); + expect(state.videoCaption.captionBlur).toHaveBeenCalled(); + + $(this).trigger('keydown'); + expect(state.videoCaption.captionKeyDown).toHaveBeenCalled(); }); }); it('set rendered to true', function () { + state = jasmine.initializePlayer(); expect(state.videoCaption.rendered).toBeTruthy(); }); }); @@ -180,9 +266,6 @@ window.onTouchBasedDevice.andReturn(['iPad']); state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - $.fn.scrollTo.reset(); }); @@ -200,12 +283,9 @@ describe('when no captions file was specified', function () { beforeEach(function () { - loadFixtures('video_all.html'); - - // Unspecify the captions file. - $('#example').find('#video_id').data('sub', ''); - - state = new Video('#example'); + state = jasmine.initializePlayer('video_all.html', { + 'sub': '' + }); }); it('captions panel is not shown', function () { @@ -218,6 +298,7 @@ beforeEach(function () { jasmine.Clock.useMock(); spyOn(window, 'clearTimeout'); + state = jasmine.initializePlayer(); }); describe('when cursor is outside of the caption box', function () { @@ -313,8 +394,254 @@ }); }); + it('reRenderCaption', function () { + var Caption = state.videoCaption, + li; + + Caption.captions = ['test']; + Caption.start = [500]; + + spyOn(Caption, 'addPaddings'); + + Caption.reRenderCaption(); + li = $('ol.subtitles li'); + + expect(Caption.addPaddings).toHaveBeenCalled(); + expect(li.length).toBe(1); + expect(li).toHaveData('start', '500'); + }); + + describe('fetchCaption', function () { + var Caption, msg; + + beforeEach(function () { + state = jasmine.initializePlayer(); + Caption = state.videoCaption; + spyOn($, 'ajaxWithPrefix').andCallThrough(); + spyOn(Caption, 'reRenderCaption'); + spyOn(Caption, 'renderCaption'); + spyOn(Caption, 'bindHandlers'); + spyOn(Caption, 'updatePlayTime'); + spyOn(Caption, 'hideCaptions'); + spyOn(state, 'youtubeId').andReturn('Z5KLxerq05Y'); + }); + + it('do not fetch captions, if 1.0 speed is absent', function () { + state.youtubeId.andReturn(void(0)); + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).not.toHaveBeenCalled(); + expect(Caption.hideCaptions).not.toHaveBeenCalled(); + }); + + it('show caption on language change', function () { + Caption.loaded = true; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.hideCaptions).toHaveBeenCalledWith(false); + }); + + msg = 'use cookie to show/hide captions if they have not been ' + + 'loaded yet'; + it(msg, function () { + Caption.loaded = false; + state.hide_captions = false; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.hideCaptions).toHaveBeenCalledWith(false, false); + + Caption.loaded = false; + Caption.hideCaptions.reset(); + state.hide_captions = true; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.hideCaptions).toHaveBeenCalledWith(true, false); + }); + + it('on success: on touch devices', function () { + state.isTouch = true; + Caption.loaded = false; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.bindHandlers).toHaveBeenCalled(); + expect(Caption.renderCaption).not.toHaveBeenCalled(); + expect(Caption.updatePlayTime).not.toHaveBeenCalled(); + expect(Caption.reRenderCaption).not.toHaveBeenCalled(); + expect(Caption.loaded).toBeTruthy(); + }); + + msg = 'on success: change language on touch devices when ' + + 'captions have not been rendered yet'; + it(msg, function () { + state.isTouch = true; + Caption.loaded = true; + Caption.rendered = false; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.bindHandlers).not.toHaveBeenCalled(); + expect(Caption.renderCaption).not.toHaveBeenCalled(); + expect(Caption.updatePlayTime).not.toHaveBeenCalled(); + expect(Caption.reRenderCaption).not.toHaveBeenCalled(); + expect(Caption.loaded).toBeTruthy(); + }); + + it('on success: re-render on touch devices', function () { + state.isTouch = true; + Caption.loaded = true; + Caption.rendered = true; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.bindHandlers).not.toHaveBeenCalled(); + expect(Caption.renderCaption).not.toHaveBeenCalled(); + expect(Caption.updatePlayTime).toHaveBeenCalled(); + expect(Caption.reRenderCaption).toHaveBeenCalled(); + expect(Caption.loaded).toBeTruthy(); + }); + + it('on success: rendered correct', function () { + Caption.loaded = false; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.bindHandlers).toHaveBeenCalled(); + expect(Caption.renderCaption).toHaveBeenCalled(); + expect(Caption.updatePlayTime).not.toHaveBeenCalled(); + expect(Caption.reRenderCaption).not.toHaveBeenCalled(); + expect(Caption.loaded).toBeTruthy(); + }); + + it('on success: re-rendered correct', function () { + Caption.loaded = true; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.bindHandlers).not.toHaveBeenCalled(); + expect(Caption.renderCaption).not.toHaveBeenCalled(); + expect(Caption.updatePlayTime).toHaveBeenCalled(); + expect(Caption.reRenderCaption).toHaveBeenCalled(); + expect(Caption.loaded).toBeTruthy(); + }); + + msg = 'on error: captions are hidden if there are no transcripts'; + it(msg, function () { + spyOn(Caption, 'fetchAvailableTranslations'); + $.ajax.andCallFake(function (settings) { + settings.error([]); + }); + + state.config.transcriptLanguages = {}; + + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.fetchAvailableTranslations).not.toHaveBeenCalled(); + expect(Caption.hideCaptions.mostRecentCall.args) + .toEqual([true, false]); + expect(Caption.hideSubtitlesEl).toBeHidden(); + }); + + msg = 'on error: fetch available translations if there are ' + + 'additional transcripts'; + xit(msg, function () { + $.ajax + .andCallFake(function (settings) { + settings.error([]); + }); + + state.config.transcriptLanguages = { + 'en': 'English', + 'uk': 'Ukrainian', + }; + + spyOn(Caption, 'fetchAvailableTranslations'); + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.fetchAvailableTranslations).toHaveBeenCalled(); + expect(Caption.hideCaptions).not.toHaveBeenCalled(); + }); + }); + + describe('fetchAvailableTranslations', function () { + var Caption, msg; + + beforeEach(function () { + state = jasmine.initializePlayer(); + Caption = state.videoCaption; + spyOn($, 'ajaxWithPrefix').andCallThrough(); + spyOn(Caption, 'hideCaptions'); + spyOn(Caption, 'fetchCaption'); + spyOn(Caption, 'renderLanguageMenu'); + }); + + it('request created with correct parameters', function () { + Caption.fetchAvailableTranslations(); + + expect($.ajaxWithPrefix).toHaveBeenCalledWith({ + url: '/transcript/available_translations', + notifyOnError: false, + success: jasmine.any(Function), + error: jasmine.any(Function) + }); + }); + + msg = 'on succes: language menu is rendered if translations available'; + it(msg, function () { + state.config.transcriptLanguages = { + 'en': 'English', + 'uk': 'Ukrainian', + 'de': 'German' + }; + Caption.fetchAvailableTranslations(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.fetchCaption).toHaveBeenCalled(); + expect(state.config.transcriptLanguages).toEqual({ + 'uk': 'Ukrainian', + 'de': 'German' + }); + expect(Caption.renderLanguageMenu).toHaveBeenCalledWith({ + 'uk': 'Ukrainian', + 'de': 'German' + }); + }); + + msg = 'on succes: language menu isn\'t rendered if translations unavailable'; + it(msg, function () { + state.config.transcriptLanguages = { + 'en': 'English', + 'ru': 'Russian' + }; + Caption.fetchAvailableTranslations(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.fetchCaption).not.toHaveBeenCalled(); + expect(state.config.transcriptLanguages).toEqual({}); + expect(Caption.renderLanguageMenu).not.toHaveBeenCalled(); + }); + + msg = 'on error: captions are hidden if there are no transcript'; + it(msg, function () { + $.ajax.andCallFake(function (settings) { + settings.error(); + }); + Caption.fetchAvailableTranslations(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.hideCaptions).toHaveBeenCalledWith(true, false); + expect(Caption.hideSubtitlesEl).toBeHidden(); + }); + }); + describe('search', function () { it('return a correct caption index', function () { + state = jasmine.initializePlayer(); expect(state.videoCaption.search(0)).toEqual(-1); expect(state.videoCaption.search(3120)).toEqual(1); expect(state.videoCaption.search(6270)).toEqual(2); @@ -328,13 +655,7 @@ describe('when the caption was not rendered', function () { beforeEach(function () { window.onTouchBasedDevice.andReturn(['iPad']); - state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); - state.videoCaption.play(); }); @@ -359,34 +680,6 @@ expect($('.subtitles li:last')).toBe('.spacing'); }); - it('bind all the caption link', function () { - $('.subtitles li[data-index]').each( - function (index, link) { - - expect($(link)).toHandleWith( - 'mouseover', state.videoCaption.captionMouseOverOut - ); - expect($(link)).toHandleWith( - 'mouseout', state.videoCaption.captionMouseOverOut - ); - expect($(link)).toHandleWith( - 'mousedown', state.videoCaption.captionMouseDown - ); - expect($(link)).toHandleWith( - 'click', state.videoCaption.captionClick - ); - expect($(link)).toHandleWith( - 'focus', state.videoCaption.captionFocus - ); - expect($(link)).toHandleWith( - 'blur', state.videoCaption.captionBlur - ); - expect($(link)).toHandleWith( - 'keydown', state.videoCaption.captionKeyDown - ); - }); - }); - it('set rendered to true', function () { expect(state.videoCaption.rendered).toBeTruthy(); }); @@ -399,6 +692,7 @@ describe('pause', function () { beforeEach(function () { + state = jasmine.initializePlayer(); state.videoCaption.playing = true; state.videoCaption.pause(); }); @@ -409,6 +703,10 @@ }); describe('updatePlayTime', function () { + beforeEach(function () { + state = jasmine.initializePlayer(); + }); + describe('when the video speed is 1.0x', function () { beforeEach(function () { state.videoSpeedControl.currentSpeed = '1.0'; @@ -475,11 +773,7 @@ describe('resize', function () { beforeEach(function () { state = jasmine.initializePlayer(); - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); - $('.subtitles li[data-index=1]').addClass('current'); state.videoCaption.resize(); }); @@ -542,10 +836,6 @@ xdescribe('scrollCaption', function () { beforeEach(function () { state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); }); describe('when frozen', function () { @@ -590,6 +880,10 @@ // Disabled 10/9/13 due to flakiness in master xdescribe('seekPlayer', function () { + beforeEach(function () { + state = jasmine.initializePlayer(); + }); + describe('when the video speed is 1.0x', function () { beforeEach(function () { state.videoSpeedControl.currentSpeed = '1.0'; @@ -603,12 +897,6 @@ describe('when the video speed is not 1.0x', function () { beforeEach(function () { - state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); - state.videoSpeedControl.currentSpeed = '0.75'; $('.subtitles li[data-start="14910"]').trigger('click'); }); @@ -622,12 +910,6 @@ function () { beforeEach(function () { - state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); - state.videoSpeedControl.currentSpeed = '0.75'; state.currentPlayerMode = 'flash'; $('.subtitles li[data-start="14910"]').trigger('click'); @@ -642,11 +924,6 @@ describe('toggle', function () { beforeEach(function () { state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); - spyOn(state.videoPlayer, 'log'); $('.subtitles li[data-index=1]').addClass('current'); }); @@ -722,10 +999,6 @@ describe('caption accessibility', function () { beforeEach(function () { state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); }); describe('when getting focus through TAB key', function () { diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js index 3dfba3008d..ec4f62f7e2 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js @@ -1,4 +1,10 @@ -(function (undefined) { +(function (requirejs, require, define, undefined) { + +'use strict'; + +require( +['video/03_video_player.js'], +function (VideoPlayer) { describe('VideoPlayer', function () { var state, oldOTBD; @@ -11,7 +17,9 @@ afterEach(function () { $('source').remove(); window.onTouchBasedDevice = oldOTBD; - state.storage.clear(); + if (state.storage) { + state.storage.clear(); + } }); describe('constructor', function () { @@ -39,8 +47,8 @@ expect(state.videoCaption).toBeDefined(); expect(state.youtubeId('1.0')).toEqual('Z5KLxerq05Y'); expect(state.speed).toEqual('1.50'); - expect(state.config.captionAssetPath) - .toEqual('/static/subs/'); + expect(state.config.transcriptTranslationUrl) + .toEqual('/transcript/translation'); }); it('create video speed control', function () { @@ -307,7 +315,7 @@ }); waitsFor(function () { - duration = state.videoPlayer.duration(); + var duration = state.videoPlayer.duration(); return duration > 0 && state.videoPlayer.isPlaying(); }, 'video begins playing', WAIT_TIMEOUT); @@ -379,85 +387,33 @@ }); }); - describe('onSpeedChange', function () { + describe('when the video is not playing', function () { beforeEach(function () { state = jasmine.initializePlayer(); - state.videoEl = $('video, iframe'); - spyOn(state.videoPlayer, 'updatePlayTime').andCallThrough(); spyOn(state, 'setSpeed').andCallThrough(); spyOn(state.videoPlayer, 'log').andCallThrough(); spyOn(state.videoPlayer.player, 'setPlaybackRate').andCallThrough(); + spyOn(state.videoPlayer, 'setPlaybackRate').andCallThrough(); }); - describe('always', function () { - beforeEach(function () { - - state.videoPlayer.currentTime = 60; - state.videoPlayer.onSpeedChange('0.75', false); - }); - - it('check if speed_change_video is logged', function () { - expect(state.videoPlayer.log).toHaveBeenCalledWith( - 'speed_change_video', - { - current_time: state.videoPlayer.currentTime, - old_speed: '1.50', - new_speed: '0.75' - } - ); - }); - - it('convert the current time to the new speed', function () { - expect(state.videoPlayer.currentTime).toEqual(60); - }); - - it('set video speed to the new speed', function () { - expect(state.setSpeed).toHaveBeenCalledWith('0.75', true); - }); + it('video has a correct speed', function () { + state.speed = '2.0'; + state.videoPlayer.onPlay(); + expect(state.videoPlayer.setPlaybackRate) + .toHaveBeenCalledWith('2.0'); + state.videoPlayer.onPlay(); + expect(state.videoPlayer.setPlaybackRate.calls.length) + .toEqual(1); }); - describe('when the video is playing', function () { - beforeEach(function () { - state.videoPlayer.currentTime = 60; - state.videoPlayer.play(); - state.videoPlayer.onSpeedChange('0.75', false); - }); - - it('trigger updatePlayTime event', function () { - expect(state.videoPlayer.player.setPlaybackRate) - .toHaveBeenCalledWith('0.75'); - }); - }); - - describe('when the video is not playing', function () { - beforeEach(function () { - state.videoPlayer.onSpeedChange('0.75', false); - }); - - it('trigger updatePlayTime event', function () { - expect(state.videoPlayer.player.setPlaybackRate) - .toHaveBeenCalledWith('0.75'); - }); - - it('video has a correct speed', function () { - spyOn(state.videoPlayer, 'onSpeedChange'); - state.speed = '2.0'; - state.videoPlayer.onPlay(); - expect(state.videoPlayer.onSpeedChange) - .toHaveBeenCalledWith('2.0'); - state.videoPlayer.onPlay(); - expect(state.videoPlayer.onSpeedChange.calls.length).toEqual(1); - }); - - it('video has a correct volume', function () { - spyOn(state.videoPlayer.player, 'setVolume'); - state.currentVolume = '0.26'; - state.videoPlayer.onPlay(); - expect(state.videoPlayer.player.setVolume) - .toHaveBeenCalledWith('0.26'); - }); + it('video has a correct volume', function () { + spyOn(state.videoPlayer.player, 'setVolume'); + state.currentVolume = '0.26'; + state.videoPlayer.onPlay(); + expect(state.videoPlayer.player.setVolume) + .toHaveBeenCalledWith('0.26'); }); }); @@ -789,7 +745,7 @@ state.el.addClass('video-fullscreen'); state.videoControl.fullScreenState = true; - isFullScreen = true; + state.videoControl.isFullScreen = true; state.videoControl.fullScreenEl.attr('title', 'Exit-fullscreen'); state.videoControl.toggleFullScreen(jQuery.Event('click')); @@ -931,20 +887,6 @@ }); }); - describe('playback rate', function () { - beforeEach(function () { - state = jasmine.initializePlayer(); - - state.videoEl = $('video, iframe'); - - state.videoPlayer.player.setPlaybackRate(1.5); - }); - - it('set the player playback rate', function () { - expect(state.videoPlayer.player.video.playbackRate).toEqual(1.5); - }); - }); - describe('volume', function () { beforeEach(function () { state = jasmine.initializePlayer(); @@ -1023,7 +965,7 @@ }); waitsFor(function () { - duration = state.videoPlayer.duration(); + var duration = state.videoPlayer.duration(); return duration > 0 && state.videoPlayer.isPlaying(); },'Video does not play.' , WAIT_TIMEOUT); @@ -1034,6 +976,108 @@ }); }); }); - }); -}).call(this); + describe('onSpeedChange', function () { + beforeEach(function () { + state = { + el: $(document), + speed: '1.50', + setSpeed: jasmine.createSpy(), + saveState: jasmine.createSpy(), + videoPlayer: { + currentTime: 60, + log: jasmine.createSpy(), + updatePlayTime: jasmine.createSpy(), + setPlaybackRate: jasmine.createSpy(), + player: jasmine.createSpyObj('player', ['setPlaybackRate']) + } + }; + }); + + describe('always', function () { + it('check if speed_change_video is logged', function () { + VideoPlayer.prototype.onSpeedChange.call(state, '0.75', false); + expect(state.videoPlayer.log).toHaveBeenCalledWith( + 'speed_change_video', + { + current_time: state.videoPlayer.currentTime, + old_speed: '1.50', + new_speed: '0.75' + } + ); + }); + + it('convert the current time to the new speed', function () { + state.currentPlayerMode = 'flash'; + VideoPlayer.prototype.onSpeedChange.call(state, '0.75', false); + expect(state.videoPlayer.currentTime).toBe('120.000'); + }); + + it('set video speed to the new speed', function () { + VideoPlayer.prototype.onSpeedChange.call(state, '0.75', false); + expect(state.setSpeed).toHaveBeenCalledWith('0.75', true); + expect(state.saveState).toHaveBeenCalledWith(true, { + speed: '0.75' + }); + expect(state.videoPlayer.setPlaybackRate) + .toHaveBeenCalledWith('0.75'); + }); + }); + }); + + describe('setPlaybackRate', function () { + beforeEach(function () { + state = { + youtubeId: jasmine.createSpy().andReturn('videoId'), + videoPlayer: { + currentTime: 60, + isPlaying: jasmine.createSpy(), + updatePlayTime: jasmine.createSpy(), + setPlaybackRate: jasmine.createSpy(), + player: jasmine.createSpyObj('player', [ + 'setPlaybackRate', 'loadVideoById', 'cueVideoById' + ]) + } + }; + }); + + it('in Flash mode and video is playing', function () { + state.currentPlayerMode = 'flash'; + state.videoPlayer.isPlaying.andReturn(true); + VideoPlayer.prototype.setPlaybackRate.call(state, '0.75'); + expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60); + expect(state.videoPlayer.player.loadVideoById) + .toHaveBeenCalledWith('videoId', 60); + }); + + it('in Flash mode and video not started', function () { + state.currentPlayerMode = 'flash'; + state.videoPlayer.isPlaying.andReturn(false); + VideoPlayer.prototype.setPlaybackRate.call(state, '0.75'); + expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60); + expect(state.videoPlayer.player.cueVideoById) + .toHaveBeenCalledWith('videoId', 60); + }); + + it('in HTML5 mode', function () { + state.currentPlayerMode = 'html5'; + VideoPlayer.prototype.setPlaybackRate.call(state, '0.75'); + expect(state.videoPlayer.player.setPlaybackRate).toHaveBeenCalledWith('0.75'); + }); + + it('Youtube video in FF, with new speed equal 1.0', function () { + state.currentPlayerMode = 'html5'; + state.videoType = 'youtube'; + state.browserIsFirefox = true; + + state.videoPlayer.isPlaying.andReturn(false); + VideoPlayer.prototype.setPlaybackRate.call(state, '1.0'); + expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60); + expect(state.videoPlayer.player.cueVideoById) + .toHaveBeenCalledWith('videoId', 60); + }); + }); + }); +}); + +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js index 8ab4e0b785..7cea30aa9e 100644 --- a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js +++ b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js @@ -57,9 +57,11 @@ function (VideoPlayer, VideoStorage) { }); }); }, + methodsDict = { bindTo: bindTo, fetchMetadata: fetchMetadata, + getCurrentLanguage: getCurrentLanguage, getDuration: getDuration, getVideoMetadata: getVideoMetadata, initialize: initialize, @@ -305,6 +307,11 @@ function (VideoPlayer, VideoStorage) { value || '1.0'; }, + 'transcriptLanguage': function (value) { + return storage.getItem('language') || + value || + 'en'; + }, 'ytTestTimeout': function (value) { value = parseInt(value, 10); @@ -432,6 +439,7 @@ function (VideoPlayer, VideoStorage) { this.config.endTime = null; } + this.lang = this.config.transcriptLanguage; this.speed = Number( this.config.speed || this.config.generalSpeed ).toFixed(2).replace(/\.00$/, '.0'); @@ -631,17 +639,16 @@ function (VideoPlayer, VideoStorage) { 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] + // HTML5 = [0.75, 1, 1.25, 1.5] + // Youtube Flash = [0.75, 1, 1.25, 1.5] + // Youtube HTML5 = [0.25, 0.5, 1, 1.5, 2] var map = { - '0.25': '0.75', - '0.50': '0.75', - '0.75': '0.50', - '1.25': '1.50', - '2.0': '1.50' - }, - useSession = true; + '0.25': '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash + '0.50': '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash + '0.75': '0.50', // HTML5 or Youtube Flash -> Youtube HTML5 + '1.25': '1.50', // HTML5 or Youtube Flash -> Youtube HTML5 + '2.0': '1.50' // Youtube HTML5 -> HTML5 or Youtube Flash + }; if (_.contains(this.speeds, newSpeed)) { this.speed = newSpeed; @@ -712,6 +719,24 @@ function (VideoPlayer, VideoStorage) { } } + function getCurrentLanguage() { + var keys = _.keys(this.config.transcriptLanguages); + + if (keys.length) { + if (!_.contains(keys, this.lang)) { + if (_.contains(keys, 'en')) { + this.lang = 'en'; + } else { + this.lang = keys.pop(); + } + } + } else { + return null; + } + + return this.lang; + } + /* * The trigger() function will assume that the @objChain is a complete * chain with a method (function) at the end. It will call this function. 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 bd80c92aea..b225d0783e 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 @@ -5,30 +5,16 @@ define( 'video/03_video_player.js', ['video/02_html5_video.js', 'video/00_resizer.js'], function (HTML5Video, Resizer) { - var dfd = $.Deferred(); + var dfd = $.Deferred(), + VideoPlayer = function (state) { + state.videoPlayer = {}; + _makeFunctionsPublic(state); + _initialize(state); + // No callbacks to DOM events (click, mousemove, etc.). - // VideoPlayer() function - what this module "exports". - return function (state) { - - state.videoPlayer = {}; - - _makeFunctionsPublic(state); - _initialize(state); - // No callbacks to DOM events (click, mousemove, etc.). - - return dfd.promise(); - }; - - // *************************************************************** - // Private functions start here. - // *************************************************************** - - // function _makeFunctionsPublic(state) - // - // Functions which will be accessible via 'state' object. When called, - // these functions will get the 'state' object as a context. - function _makeFunctionsPublic(state) { - var methodsDict = { + return dfd.promise(); + }, + methodsDict = { duration: duration, handlePlaybackQualityChange: handlePlaybackQualityChange, isPlaying: isPlaying, @@ -46,10 +32,25 @@ function (HTML5Video, Resizer) { onVolumeChange: onVolumeChange, pause: pause, play: play, + setPlaybackRate: setPlaybackRate, update: update, updatePlayTime: updatePlayTime }; + VideoPlayer.prototype = methodsDict; + + // VideoPlayer() function - what this module "exports". + return VideoPlayer; + + // *************************************************************** + // Private functions start here. + // *************************************************************** + + // function _makeFunctionsPublic(state) + // + // Functions which will be accessible via 'state' object. When called, + // these functions will get the 'state' object as a context. + function _makeFunctionsPublic(state) { state.bindTo(methodsDict, state.videoPlayer, state); } @@ -70,7 +71,7 @@ function (HTML5Video, Resizer) { $(window).on('unload', state.saveState); if (state.currentPlayerMode !== 'flash') { - state.videoPlayer.onSpeedChange(state.speed); + state.videoPlayer.setPlaybackRate(state.speed); } state.videoPlayer.player.setVolume(state.currentVolume); }); @@ -325,33 +326,10 @@ function (HTML5Video, Resizer) { } } - function onSpeedChange(newSpeed) { + function setPlaybackRate(newSpeed) { var time = this.videoPlayer.currentTime, methodName, youtubeId; - if (this.currentPlayerMode === 'flash') { - this.videoPlayer.currentTime = Time.convert( - time, - parseFloat(this.speed), - newSpeed - ); - } - - newSpeed = parseFloat(newSpeed).toFixed(2).replace(/\.00$/, '.0'); - - if (this.speed != newSpeed) { - this.videoPlayer.log( - 'speed_change_video', - { - current_time: time, - old_speed: this.speed, - new_speed: newSpeed - } - ); - } - - this.setSpeed(newSpeed, true); - if ( this.currentPlayerMode === 'html5' && !( @@ -377,7 +355,33 @@ function (HTML5Video, Resizer) { this.videoPlayer.player[methodName](youtubeId, time); this.videoPlayer.updatePlayTime(time); } + } + function onSpeedChange(newSpeed) { + var time = this.videoPlayer.currentTime, + isFlash = this.currentPlayerMode === 'flash'; + + if (isFlash) { + this.videoPlayer.currentTime = Time.convert( + time, + parseFloat(this.speed), + newSpeed + ); + } + + newSpeed = parseFloat(newSpeed).toFixed(2).replace(/\.00$/, '.0'); + + this.videoPlayer.log( + 'speed_change_video', + { + current_time: time, + old_speed: this.speed, + new_speed: newSpeed + } + ); + + this.setSpeed(newSpeed, true); + this.videoPlayer.setPlaybackRate(newSpeed); this.el.trigger('speedchange', arguments); this.saveState(true, { speed: newSpeed }); 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 edea7bcdba..06ff56e180 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 @@ -43,8 +43,7 @@ function () { // these functions will get the 'state' object as a context. function _makeFunctionsPublic(state) { var methodsDict = { - autoHideCaptions: autoHideCaptions, - autoShowCaptions: autoShowCaptions, + addPaddings: addPaddings, bindHandlers: bindHandlers, bottomSpacingHeight: bottomSpacingHeight, calculateOffset: calculateOffset, @@ -55,8 +54,8 @@ function () { captionKeyDown: captionKeyDown, captionMouseDown: captionMouseDown, captionMouseOverOut: captionMouseOverOut, - captionURL: captionURL, fetchCaption: fetchCaption, + fetchAvailableTranslations: fetchAvailableTranslations, hideCaptions: hideCaptions, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, @@ -65,6 +64,8 @@ function () { play: play, renderCaption: renderCaption, renderElements: renderElements, + renderLanguageMenu: renderLanguageMenu, + reRenderCaption: reRenderCaption, resize: resize, scrollCaption: scrollCaption, search: search, @@ -105,14 +106,24 @@ function () { * and the CC button will be hidden. */ function renderElements() { - this.videoCaption.loaded = false; + var Caption = this.videoCaption, + languages = this.config.transcriptLanguages; - this.videoCaption.subtitlesEl = this.el.find('ol.subtitles'); - this.videoCaption.hideSubtitlesEl = this.el.find('a.hide-subtitles'); + Caption.loaded = false; + Caption.subtitlesEl = this.el.find('ol.subtitles'); + Caption.container = this.el.find('.lang'); + Caption.hideSubtitlesEl = this.el.find('a.hide-subtitles'); - if (!this.videoCaption.fetchCaption()) { - this.videoCaption.hideCaptions(true); - this.videoCaption.hideSubtitlesEl.hide(); + if (_.keys(languages).length) { + Caption.renderLanguageMenu(languages); + + if (!Caption.fetchCaption()) { + Caption.hideCaptions(true); + Caption.hideSubtitlesEl.hide(); + } + } else { + Caption.hideCaptions(true, false); + Caption.hideSubtitlesEl.hide(); } } @@ -121,203 +132,50 @@ function () { // Bind any necessary function callbacks to DOM events (click, // mousemove, etc.). function bindHandlers() { - $(window).bind('resize', this.videoCaption.resize); - this.videoCaption.hideSubtitlesEl.on( - 'click', this.videoCaption.toggle - ); + var self = this, + Caption = this.videoCaption; - this.videoCaption.subtitlesEl - .on( - 'mouseenter', - this.videoCaption.onMouseEnter - ).on( - 'mouseleave', - this.videoCaption.onMouseLeave - ).on( - 'mousemove', - this.videoCaption.onMovement - ).on( - 'mousewheel', - this.videoCaption.onMovement - ).on( - 'DOMMouseScroll', - this.videoCaption.onMovement - ); + $(window).bind('resize', Caption.resize); + Caption.hideSubtitlesEl.on({ + 'click': Caption.toggle + }); - if ((this.videoType === 'html5') && (this.config.autohideHtml5)) { - this.el.on({ - mousemove: this.videoCaption.autoShowCaptions, - keydown: this.videoCaption.autoShowCaptions - }); + Caption.subtitlesEl.on({ + mouseenter: Caption.onMouseEnter, + mouseleave: Caption.onMouseLeave, + mousemove: Caption.onMovement, + mousewheel: Caption.onMovement, + DOMMouseScroll: Caption.onMovement + }); - // Moving slider on subtitles is not a mouse move, but captions and - // controls should be shown. - this.videoCaption.subtitlesEl - .on( - 'scroll', this.videoCaption.autoShowCaptions - ) - .on( - 'scroll', this.videoControl.showControls - ); - } else if (!this.config.autohideHtml5) { - this.videoCaption.subtitlesEl.on({ - keydown: this.videoCaption.autoShowCaptions, - focus: this.videoCaption.autoShowCaptions, - - // Moving slider on subtitles is not a mouse move, but captions - // should not be auto-hidden. - scroll: this.videoCaption.autoShowCaptions, - - mouseout: this.videoCaption.autoHideCaptions, - blur: this.videoCaption.autoHideCaptions - }); - - this.videoCaption.hideSubtitlesEl.on({ - mousemove: this.videoCaption.autoShowCaptions, - - mouseout: this.videoCaption.autoHideCaptions, - blur: this.videoCaption.autoHideCaptions + if (Caption.showLanguageMenu) { + Caption.container.on({ + mouseenter: onContainerMouseEnter, + mouseleave: onContainerMouseLeave }); } - } - /** - * @desc Fetch the caption file specified by the user. Upn successful - * receival of the file, the captions will be rendered. - * - * @type {function} - * @access public - * - * @this {object} - The object containg the state of the video - * player. All other modules, their parameters, public variables, etc. - * are available via this object. - * - * @returns {boolean} - * true: The user specified a caption file. NOTE: if an error happens - * while the specified file is being retrieved (for example the - * file is missing on the server), this function will still return - * true. - * false: No caption file was specified, or an empty string was - * specified. - */ - function fetchCaption() { - var _this = this; - - // Check whether the captions file was specified. This is the point - // where we either stop with the caption panel (so that a white empty - // panel to the right of the video will not be shown), or carry on - // further. - if (!this.youtubeId('1.0')) { - return false; - } - - this.videoCaption.hideCaptions(this.hide_captions); - - // Fetch the captions file. If no file was specified, or if an error - // occurred, then we hide the captions panel, and the "CC" button - $.ajaxWithPrefix({ - url: _this.videoCaption.captionURL(), - notifyOnError: false, - success: function (captions) { - _this.videoCaption.captions = captions.text; - _this.videoCaption.start = captions.start; - _this.videoCaption.loaded = true; - - if (_this.isTouch) { - _this.videoCaption.subtitlesEl.find('li').html( - gettext( - 'Caption will be displayed when ' + - 'you start playing the video.' - ) - ); - } else { - _this.videoCaption.renderCaption(); - } - - _this.videoCaption.bindHandlers(); - }, - error: function (jqXHR, textStatus, errorThrown) { - console.log('[Video info]: ERROR while fetching captions.'); - console.log( - '[Video info]: STATUS:', textStatus + - ', MESSAGE:', '' + errorThrown - ); - - _this.videoCaption.hideCaptions(true, false); - _this.videoCaption.hideSubtitlesEl.hide(); + this.el.on('speedchange', function () { + if (self.currentPlayerMode === 'flash') { + Caption.fetchCaption(); } }); - return true; - } - - function captionURL() { - return '' + this.config.captionAssetPath + - this.youtubeId('1.0') + '.srt.sjson'; - } - - function autoShowCaptions(event) { - if (!this.captionsShowLock) { - if (!this.captionsHidden) { - return; - } - - this.captionsShowLock = true; - - if (this.captionState === 'invisible') { - this.videoCaption.subtitlesEl.show(); - this.captionState = 'visible'; - } else if (this.captionState === 'hiding') { - this.videoCaption.subtitlesEl - .stop(true, false).css('opacity', 1).show(); - this.captionState = 'visible'; - } else if (this.captionState === 'visible') { - clearTimeout(this.captionHideTimeout); - } - - if (this.config.autohideHtml5) { - this.captionHideTimeout = setTimeout( - this.videoCaption.autoHideCaptions, - this.videoCaption.fadeOutTimeout - ); - } - - this.captionsShowLock = false; + if ((this.videoType === 'html5') && (this.config.autohideHtml5)) { + Caption.subtitlesEl.on('scroll', this.videoControl.showControls); } } - function autoHideCaptions() { - var _this; + function onContainerMouseEnter(event) { + event.preventDefault(); - this.captionHideTimeout = null; - - if (!this.captionsHidden) { - return; - } - - this.captionState = 'hiding'; - - _this = this; - - this.videoCaption.subtitlesEl - .fadeOut( - this.videoCaption.fadeOutTimeout, - function () { - _this.captionState = 'invisible'; - } - ); + $(event.currentTarget).addClass('open'); } - function resize() { - this.videoCaption.subtitlesEl - .find('.spacing:first') - .height(this.videoCaption.topSpacingHeight()) - .find('.spacing:last') - .height(this.videoCaption.bottomSpacingHeight()); + function onContainerMouseLeave(event) { + event.preventDefault(); - this.videoCaption.scrollCaption(); - - this.videoCaption.setSubtitlesHeight(); + $(event.currentTarget).removeClass('open'); } function onMouseEnter() { @@ -344,76 +202,275 @@ function () { } function onMovement() { - if (!this.config.autohideHtml5) { - this.videoCaption.autoShowCaptions(); - } - this.videoCaption.onMouseEnter(); } - function renderCaption() { - var container = $('
    '), - _this = this, - autohideHtml5 = this.config.autohideHtml5; - - this.container.after(this.videoCaption.subtitlesEl); - this.el.find('.video-controls .secondary-controls') - .append(this.videoCaption.hideSubtitlesEl); - - this.videoCaption.setSubtitlesHeight(); - - if ((this.videoType === 'html5' && autohideHtml5) || !autohideHtml5) { - this.videoCaption.fadeOutTimeout = this.config.fadeOutTimeout; - this.videoCaption.subtitlesEl.addClass('html5'); + /** + * @desc Fetch the caption file specified by the user. Upn successful + * receival of the file, the captions will be rendered. + * + * @type {function} + * @access public + * + * @this {object} - The object containg the state of the video + * player. All other modules, their parameters, public variables, etc. + * are available via this object. + * + * @returns {boolean} + * true: The user specified a caption file. NOTE: if an error happens + * while the specified file is being retrieved (for example the + * file is missing on the server), this function will still return + * true. + * false: No caption file was specified, or an empty string was + * specified. + */ + function fetchCaption() { + var self = this, + Caption = self.videoCaption; + // Check whether the captions file was specified. This is the point + // where we either stop with the caption panel (so that a white empty + // panel to the right of the video will not be shown), or carry on + // further. + if (!this.youtubeId('1.0')) { + return false; } - $.each(this.videoCaption.captions, function(index, text) { + if (Caption.loaded) { + Caption.hideCaptions(false); + } else { + Caption.hideCaptions(this.hide_captions, false); + } + + if (Caption.fetchXHR && Caption.fetchXHR.abort) { + Caption.fetchXHR.abort(); + } + + // Fetch the captions file. If no file was specified, or if an error + // occurred, then we hide the captions panel, and the "CC" button + Caption.fetchXHR = $.ajaxWithPrefix({ + url: self.config.transcriptTranslationUrl, + notifyOnError: false, + data: { + videoId: this.youtubeId(), + language: this.getCurrentLanguage() + }, + success: function (captions) { + Caption.captions = captions.text; + Caption.start = captions.start; + + if (Caption.loaded) { + if (Caption.rendered) { + Caption.reRenderCaption(); + Caption.updatePlayTime(self.videoPlayer.currentTime); + } + } else { + if (self.isTouch) { + Caption.subtitlesEl.find('li').html( + gettext( + 'Caption will be displayed when ' + + 'you start playing the video.' + ) + ); + } else { + Caption.renderCaption(); + } + + Caption.bindHandlers(); + } + + Caption.loaded = true; + }, + error: function (jqXHR, textStatus, errorThrown) { + console.log('[Video info]: ERROR while fetching captions.'); + console.log( + '[Video info]: STATUS:', textStatus + + ', MESSAGE:', '' + errorThrown + ); + // If initial list of languages has more than 1 item, check + // for availability other transcripts. + if (_.keys(self.config.transcriptLanguages).length > 1) { + Caption.fetchAvailableTranslations(); + } else { + Caption.hideCaptions(true, false); + Caption.hideSubtitlesEl.hide(); + } + } + }); + + return true; + } + + function fetchAvailableTranslations() { + var self = this, + Caption = this.videoCaption; + + return $.ajaxWithPrefix({ + url: self.config.transcriptAvailableTranslationsUrl, + notifyOnError: false, + success: function (response) { + var currentLanguages = self.config.transcriptLanguages, + newLanguages = _.pick(currentLanguages, response); + + // Update property with available currently translations. + self.config.transcriptLanguages = newLanguages; + // Remove an old language menu. + Caption.container.find('.langs-list').remove(); + + if (_.keys(newLanguages).length) { + // And try again to fetch transcript. + Caption.fetchCaption(); + Caption.renderLanguageMenu(newLanguages); + } + }, + error: function (jqXHR, textStatus, errorThrown) { + Caption.hideCaptions(true, false); + Caption.hideSubtitlesEl.hide(); + } + }); + } + + function resize() { + this.videoCaption.subtitlesEl + .find('.spacing:first') + .height(this.videoCaption.topSpacingHeight()) + .find('.spacing:last') + .height(this.videoCaption.bottomSpacingHeight()); + + this.videoCaption.scrollCaption(); + + this.videoCaption.setSubtitlesHeight(); + } + + function renderLanguageMenu(languages) { + var self = this, + menu = $('