From 3a740c04797a24603723338f330c8ce189e0496f Mon Sep 17 00:00:00 2001 From: jmclaus Date: Tue, 25 Feb 2014 09:38:24 +0100 Subject: [PATCH] BLD-844: Add possibility to download transcripts in different formats. --- CHANGELOG.rst | 2 + cms/static/sass/elements/_xmodules.scss | 9 + .../xmodule/css/video/accessible_menu.scss | 133 ++++++++ .../xmodule/xmodule/css/video/display.scss | 17 +- .../xmodule/js/fixtures/video_all.html | 17 + .../spec/video/video_accessible_menu_spec.js | 307 +++++++++++++++++ .../js/src/video/035_video_accessible_menu.js | 308 ++++++++++++++++++ .../xmodule/xmodule/js/src/video/10_main.js | 3 + .../xmodule/video_module/video_module.py | 65 +++- .../data/uploads/subs_OEoXaMPEzfM.srt.sjson | 2 +- .../courseware/features/video.feature | 21 ++ lms/djangoapps/courseware/features/video.py | 103 +++++- .../courseware/tests/test_video_handlers.py | 48 ++- .../courseware/tests/test_video_mongo.py | 9 +- lms/templates/video.html | 24 +- 15 files changed, 1037 insertions(+), 31 deletions(-) create mode 100644 common/lib/xmodule/xmodule/css/video/accessible_menu.scss create mode 100644 common/lib/xmodule/xmodule/js/spec/video/video_accessible_menu_spec.js create mode 100644 common/lib/xmodule/xmodule/js/src/video/035_video_accessible_menu.js diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 92142399f6..c768388866 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: Add .txt and .srt options to the "download transcript" button. BLD-844. + Blades: Fix bug when transcript cutting off view in full view mode. BLD-852. Blades: Show start time or starting position on slider and VCR. BLD-823. diff --git a/cms/static/sass/elements/_xmodules.scss b/cms/static/sass/elements/_xmodules.scss index f1c9c2b480..e15b4a636b 100644 --- a/cms/static/sass/elements/_xmodules.scss +++ b/cms/static/sass/elements/_xmodules.scss @@ -22,6 +22,15 @@ .video-controls .add-fullscreen { display: none !important; // nasty, but needed to override the bad specificity of the xmodule css selectors } + + .video-tracks { + .a11y-menu-container { + .a11y-menu-list { + bottom: 100%; + top: auto; + } + } + } } } diff --git a/common/lib/xmodule/xmodule/css/video/accessible_menu.scss b/common/lib/xmodule/xmodule/css/video/accessible_menu.scss new file mode 100644 index 0000000000..f1bf571450 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/video/accessible_menu.scss @@ -0,0 +1,133 @@ +$gray: rgb(127, 127, 127); +$blue: rgb(0, 159, 230); +$gray-d1: shade($gray,20%); +$gray-l2: tint($gray,40%); +$gray-l3: tint($gray,60%); +$blue-s1: saturate($blue,15%); + +%use-font-awesome { + font-family: FontAwesome; + -webkit-font-smoothing: antialiased; + display: inline-block; + speak: none; +} + +.a11y-menu-container { + position: relative; + + &.open { + .a11y-menu-list { + display: block; + } + } + + .a11y-menu-list { + top: 100%; + margin: 0; + padding: 0; + display: none; + position: absolute; + z-index: 10; + list-style: none; + background-color: $white; + border: 1px solid #eee; + + li { + margin: 0; + padding: 0; + border-bottom: 1px solid #eee; + color: $white; + cursor: pointer; + + a { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: $gray-l2; + font-size: 14px; + line-height: 23px; + + &:hover { + color: $gray-d1; + } + } + + &.active{ + a { + color: $blue; + } + } + + &:last-child { + box-shadow: none; + border-bottom: 0; + margin-top: 0; + } + } + } +} + + +// Video track button specific styles + +.video-tracks { + .a11y-menu-container { + display: inline-block; + vertical-align: top; + border-left: 1px solid #eee; + + &.open { + > a { + background-color: $action-primary-active-bg; + color: $very-light-text; + + &:after { + color: $very-light-text; + } + } + + } + + > a { + @include transition(all 0.25s ease-in-out 0s); + @include font-size(12); + display: block; + border-radius: 0 3px 3px 0; + background-color: $very-light-text; + padding: ($baseline*.75 $baseline*1.25 $baseline*.75 $baseline*.75); + color: $gray-l2; + min-width: 1.5em; + line-height: 14px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + + &:after { + @extend %use-font-awesome; + content: "\f0d7"; + position: absolute; + right: ($baseline*.5); + top: 33%; + color: $lighter-base-font-color; + } + } + + .a11y-menu-list { + right: 0; + + li { + font-size: em(14); + + a { + border: 0; + display: block; + padding: lh(.5); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } + } +} diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index e90d688372..35c3f5fa10 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -46,13 +46,15 @@ div.video { .video-sources, .video-tracks { display: inline-block; + vertical-align: top; margin: ($baseline*.75) ($baseline/2) 0 0; - a { + > a { @include transition(all 0.25s ease-in-out 0s); @include font-size(14); - display: inline-block; - border-radius: 3px 3px 3px 3px; + line-height : 14px; + float: left; + border-radius: 3px; background-color: $very-light-text; padding: ($baseline*.75); color: $lighter-base-font-color; @@ -62,7 +64,14 @@ div.video { color: $very-light-text; } } - + } + .video-tracks { + > a { + border-radius: 3px 0 0 3px; + } + > a.external-track { + border-radius: 3px; + } } } diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_all.html b/common/lib/xmodule/xmodule/js/fixtures/video_all.html index 7a19c951a6..91773bb1ec 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_all.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_all.html @@ -69,6 +69,23 @@
+ + + diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_accessible_menu_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_accessible_menu_spec.js new file mode 100644 index 0000000000..feb332122c --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/video/video_accessible_menu_spec.js @@ -0,0 +1,307 @@ +(function (undefined) { + describe('Video Accessible Menu', function () { + var state; + + afterEach(function () { + $('source').remove(); + state.storage.clear(); + }); + + describe('constructor', function () { + describe('always', function () { + var videoTracks, container, button, menu, menuItems, + menuItemsLinks; + + beforeEach(function () { + state = jasmine.initializePlayer(); + videoTracks = $('li.video-tracks'); + container = videoTracks.children('div.a11y-menu-container'); + button = container.children('a.a11y-menu-button'); + menuList = container.children('ol.a11y-menu-list'); + menuItems = menuList.children('li.a11y-menu-item'); + menuItemsLinks = menuItems.children('a.a11y-menu-item-link'); + }); + + it('add the accessible menu', function () { + var activeMenuItem; + // Make sure we have the expected HTML structure: + // Menu container exists + expect(container.length).toBe(1); + // Only one button and one menu list per menu container. + expect(button.length).toBe(1); + expect(menuList.length).toBe(1); + // At least one menu item and one menu link per menu + // container. Exact length test? + expect(menuItems.length).toBeGreaterThan(0); + expect(menuItemsLinks.length).toBeGreaterThan(0); + expect(menuItems.length).toBe(menuItemsLinks.length); + // And one menu item is active + activeMenuItem = menuItems.filter('.active'); + expect(activeMenuItem.length).toBe(1); + + expect(activeMenuItem.children('a.a11y-menu-item-link')) + .toHaveData('value', 'srt'); + + expect(activeMenuItem.children('a.a11y-menu-item-link')) + .toHaveHtml('SubRip (.srt) file'); + + /* TO DO: Check that all the anchors contain correct text. + $.each(li.toArray().reverse(), function (index, link) { + expect($(link)).toHaveData( + 'speed', state.videoSpeedControl.speeds[index] + ); + expect($(link).find('a').text()).toBe( + state.videoSpeedControl.speeds[index] + 'x' + ); + }); + */ + }); + + it('add ARIA attributes to button, menu, and menu items links', + function () { + expect(button).toHaveAttrs({ + 'role': 'button', + 'title': '.srt', + 'aria-disabled': 'false' + }); + + expect(menuList).toHaveAttr('role', 'menu'); + + menuItemsLinks.each(function(){ + expect($(this)).toHaveAttrs({ + 'role': 'menuitem', + 'aria-disabled': 'false' + }); + }); + }); + }); + + describe('when running', function () { + var videoTracks, container, button, menu, menuItems, + menuItemsLinks, KEY = $.ui.keyCode, + + keyPressEvent = function(key) { + return $.Event('keydown', {keyCode: key}); + }, + + tabBackPressEvent = function() { + return $.Event('keydown', + {keyCode: KEY.TAB, shiftKey: true}); + }, + + tabForwardPressEvent = function() { + return $.Event('keydown', + {keyCode: KEY.TAB, shiftKey: false}); + }, + + // Get previous element in array or cyles back to the last + // if it is the first. + previousSpeed = function(index) { + return speedEntries.eq(index < 1 ? + speedEntries.length - 1 : + index - 1); + }, + + // Get next element in array or cyles back to the first if + // it is the last. + nextSpeed = function(index) { + return speedEntries.eq(index >= speedEntries.length-1 ? + 0 : + index + 1); + }; + + beforeEach(function () { + state = jasmine.initializePlayer(); + videoTracks = $('li.video-tracks'); + container = videoTracks.children('div.a11y-menu-container'); + button = container.children('a.a11y-menu-button'); + menuList = container.children('ol.a11y-menu-list'); + menuItems = menuList.children('li.a11y-menu-item'); + menuItemsLinks = menuItems.children('a.a11y-menu-item-link'); + spyOn($.fn, 'focus').andCallThrough(); + }); + + it('open/close the menu on mouseenter/mouseleave', function () { + container.mouseenter(); + expect(container).toHaveClass('open'); + container.mouseleave(); + expect(container).not.toHaveClass('open'); + }); + + it('do not close the menu on mouseleave if a menu item has ' + + 'focus', function () { + // Open menu. Focus is on last menu item. + container.trigger(keyPressEvent(KEY.ENTER)); + container.mouseenter().mouseleave(); + expect(container).toHaveClass('open'); + }); + + it('close the menu on click', function () { + container.mouseenter().click(); + expect(container).not.toHaveClass('open'); + }); + + it('close the menu on outside click', function () { + container.trigger(keyPressEvent(KEY.ENTER)); + $(window).click(); + expect(container).not.toHaveClass('open'); + }); + + it('open the menu on ENTER keydown', function () { + container.trigger(keyPressEvent(KEY.ENTER)); + expect(container).toHaveClass('open'); + expect(menuItemsLinks.last().focus).toHaveBeenCalled(); + }); + + it('open the menu on SPACE keydown', function () { + container.trigger(keyPressEvent(KEY.SPACE)); + expect(container).toHaveClass('open'); + expect(menuItemsLinks.last().focus).toHaveBeenCalled(); + }); + + it('open the menu on UP keydown', function () { + container.trigger(keyPressEvent(KEY.UP)); + expect(container).toHaveClass('open'); + expect(menuItemsLinks.last().focus).toHaveBeenCalled(); + }); + + it('close the menu on ESCAPE keydown', function () { + container.trigger(keyPressEvent(KEY.ESCAPE)); + expect(container).not.toHaveClass('open'); + }); + + it('UP and DOWN keydown function as expected on menu items', + function () { + // Iterate through list in both directions and check if + // things wrap up correctly. + var lastEntry = menuItemsLinks.length-1, i; + + // First open menu + container.trigger(keyPressEvent(KEY.UP)); + + // Iterate with UP key until we have looped. + for (i = lastEntry; i >= 0; i--) { + menuItemsLinks.eq(i).trigger(keyPressEvent(KEY.UP)); + } + + // Iterate with DOWN key until we have looped. + for (i = 0; i <= lastEntry; i++) { + menuItemsLinks.eq(i).trigger(keyPressEvent(KEY.DOWN)); + } + + // Test if each element has been called twice. + expect($.fn.focus.calls.length) + .toEqual(2*menuItemsLinks.length+1); + }); + + it('ESC keydown on menu item closes menu', function () { + // First open menu. Focus is on last speed entry. + container.trigger(keyPressEvent(KEY.UP)); + menuItemsLinks.last().trigger(keyPressEvent(KEY.ESCAPE)); + + // Menu is closed and focus has been returned to speed + // control. + expect(container).not.toHaveClass('open'); + expect(container.focus).toHaveBeenCalled(); + }); + + it('ENTER keydown on menu item selects its data and closes menu', + function () { + // First open menu. + container.trigger(keyPressEvent(KEY.UP)); + // Focus on '.txt' + menuItemsLinks.eq(0).focus(); + menuItemsLinks.eq(0).trigger(keyPressEvent(KEY.ENTER)); + + // Menu is closed, focus has been returned to container + // and file format is '.txt'. + /* TO DO + expect(container.focus).toHaveBeenCalled(); + expect($('.video_speeds li[data-speed="1.50"]')) + .toHaveClass('active'); + expect($('.speeds p.active')).toHaveHtml('1.50x'); + */ + }); + + it('SPACE keydown on menu item selects its data and closes menu', + function () { + // First open menu. + container.trigger(keyPressEvent(KEY.UP)); + // Focus on '.txt' + menuItemsLinks.eq(0).focus(); + menuItemsLinks.eq(0).trigger(keyPressEvent(KEY.SPACE)); + + // Menu is closed, focus has been returned to container + // and file format is '.txt'. + /* TO DO + expect(speedControl.focus).toHaveBeenCalled(); + expect($('.video_speeds li[data-speed="1.50"]')) + .toHaveClass('active'); + expect($('.speeds p.active')).toHaveHtml('1.50x'); + */ + }); + + // TO DO? No such behavior implemented. + xit('TAB + SHIFT keydown on speed entry closes menu and gives ' + + 'focus to Play/Pause control', function () { + // First open menu. Focus is on last speed entry. + speedControl.trigger(keyPressEvent(KEY.UP)); + speedEntries.last().trigger(tabBackPressEvent()); + + // Menu is closed and focus has been given to Play/Pause + // control. + expect(state.videoControl.playPauseEl.focus) + .toHaveBeenCalled(); + }); + + // TO DO? No such behavior implemented. + xit('TAB keydown on speed entry closes menu and gives focus ' + + 'to Volume control', function () { + // First open menu. Focus is on last speed entry. + speedControl.trigger(keyPressEvent(KEY.UP)); + speedEntries.last().trigger(tabForwardPressEvent()); + + // Menu is closed and focus has been given to Volume + // control. + expect(state.videoVolumeControl.buttonEl.focus) + .toHaveBeenCalled(); + }); + }); + }); + + // TODO + xdescribe('change file format', function () { + describe('when new file format is not the same', function () { + beforeEach(function () { + state = jasmine.initializePlayer(); + state.videoSpeedControl.setSpeed(1.0); + spyOn(state.videoPlayer, 'onSpeedChange').andCallThrough(); + + $('li[data-speed="0.75"] a').click(); + }); + + it('trigger speedChange event', function () { + expect(state.videoPlayer.onSpeedChange).toHaveBeenCalled(); + expect(state.videoSpeedControl.currentSpeed).toEqual(0.75); + }); + }); + }); + + // TODO + xdescribe('onSpeedChange', function () { + beforeEach(function () { + state = jasmine.initializePlayer(); + $('li[data-speed="1.0"] a').addClass('active'); + state.videoSpeedControl.setSpeed(0.75); + }); + + it('set the new speed as active', function () { + expect($('.video_speeds li[data-speed="1.0"]')) + .not.toHaveClass('active'); + expect($('.video_speeds li[data-speed="0.75"]')) + .toHaveClass('active'); + expect($('.speeds p.active')).toHaveHtml('0.75x'); + }); + }); + }); +}).call(this); diff --git a/common/lib/xmodule/xmodule/js/src/video/035_video_accessible_menu.js b/common/lib/xmodule/xmodule/js/src/video/035_video_accessible_menu.js new file mode 100644 index 0000000000..53e54f817c --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/video/035_video_accessible_menu.js @@ -0,0 +1,308 @@ +(function (requirejs, require, define) { + +// VideoAccessibleMenu module. +define( +'video/035_video_accessible_menu.js', +[], +function () { + + // VideoAccessibleMenu() function - what this module "exports". + return function (state) { + var dfd = $.Deferred(); + + if (state.el.find('li.video-tracks') === 0) { + dfd.resolve(); + return dfd.promise(); + } + + state.videoAccessibleMenu = { + value: state.storage.getItem('transcript_download_format') + }; + + _initialize(state); + dfd.resolve(); + return dfd.promise(); + }; + + // *************************************************************** + // Private functions start here. + // *************************************************************** + + function _initialize(state) { + _makeFunctionsPublic(state); + _renderElements(state); + _addAriaAttributes(state); + _bindHandlers(state); + } + + // 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 = { + changeFileType: changeFileType, + setValue: setValue + }; + + state.bindTo(methodsDict, state.videoAccessibleMenu, state); + } + + // function _renderElements(state) + // + // Create any necessary DOM elements, attach them, and set their + // initial configuration. Also make the created DOM elements available + // via the 'state' object. Much easier to work this way - you don't + // have to do repeated jQuery element selects. + function _renderElements(state) { + + // For the time being, we assume that the menu structure is present in + // the template HTML. In the future accessible menu plugin, everything + // inside will be generated in this + // file. + var container = state.el.find('li.video-tracks>div.a11y-menu-container'), + button = container.children('a.a11y-menu-button'), + menuList = container.children('ol.a11y-menu-list'), + menuItems = menuList.children('li.a11y-menu-item'), + menuItemsLinks = menuItems.children('a.a11y-menu-item-link'), + value = (function (val, activeElement) { + return val || activeElement.find('a').data('value') || 'srt'; + }(state.videoAccessibleMenu.value, menuItems.filter('.active'))), + msg = '.' + value; + + $.extend(state.videoAccessibleMenu, { + container: container, + button: button, + menuList: menuList, + menuItems: menuItems, + menuItemsLinks: menuItemsLinks + }); + + if (value) { + state.videoAccessibleMenu.setValue(value); + button.text(gettext(msg)); + } + } + + function _addAriaAttributes(state) { + var menu = state.videoAccessibleMenu; + + menu.button.attr({ + 'role': 'button', + 'aria-disabled': 'false' + }); + + menu.menuList.attr('role', 'menu'); + + menu.menuItemsLinks.each(function(){ + $(this).attr({ + 'role': 'menuitem', + 'aria-disabled': 'false' + }); + }); + } + + // Get previous element in array or cyles back to the last if it is the + // first. + function _previousMenuItemLink(links, index) { + return $(links.eq(index < 1 ? links.length - 1 : index - 1)); + } + + // Get next element in array or cyles back to the first if it is the last. + function _nextMenuItemLink(links, index) { + return $(links.eq(index >= links.length - 1 ? 0 : index + 1)); + } + + function _menuItemsLinksFocused(menu) { + return menu.menuItemsLinks.is(':focus'); + } + + function _openMenu(menu, without_handler) { + // When menu items have focus, the menu stays open on + // mouseleave. A _closeMenuHandler is added to the window + // element to have clicks close the menu when they happen + // outside of it. We namespace the click event to easily remove it (and + // only it) in _closeMenu. + menu.container.addClass('open'); + menu.button.text('...'); + if (!without_handler) { + $(window).on('click.currentMenu', _closeMenuHandler.bind(menu)); + } + + // @TODO: onOpen callback + } + + function _closeMenu(menu, without_handler) { + // Remove the previously added clickHandler from window element. + var msg = '.' + menu.value; + + menu.container.removeClass('open'); + menu.button.text(gettext(msg)); + if (!without_handler) { + $(window).off('click.currentMenu'); + } + + // @TODO: onClose callback + } + + function _openMenuHandler(event) { + _openMenu(this, true); + + return false; + } + + function _closeMenuHandler(event) { + // Only close the menu if no menu item link has focus or `click` event. + if (!_menuItemsLinksFocused(this) || event.type == 'click') { + _closeMenu(this, true); + } + + return false; + } + + function _toggleMenuHandler(event) { + if (this.container.hasClass('open')) { + _closeMenu(this, true); + } else { + _openMenu(this, true); + } + + return false; + } + + // Various event handlers. They all return false to stop propagation and + // prevent default behavior. + function _clickHandler(event) { + var target = $(event.currentTarget); + + this.changeFileType.call(this, event); + _closeMenu(this, true); + + return false; + } + + function _keyDownHandler(event) { + var KEY = $.ui.keyCode, + keyCode = event.keyCode, + target = $(event.currentTarget), + index; + + if (target.is('a.a11y-menu-item-link')) { + + index = target.parent().index(); + + switch (keyCode) { + // Scroll up menu, wrapping at the top. Keep menu open. + case KEY.UP: + _previousMenuItemLink(this.menuItemsLinks, index).focus(); + break; + // Scroll down menu, wrapping at the bottom. Keep menu + // open. + case KEY.DOWN: + _nextMenuItemLink(this.menuItemsLinks, index).focus(); + break; + // Close menu. + case KEY.TAB: + _closeMenu(this); + // TODO + // What has to happen here? In speed menu, tabbing backward + // will give focus to Play/Pause button and tabbing + // forward to Volume button. + break; + // Close menu, give focus to button and change + // file type. + case KEY.ENTER: + case KEY.SPACE: + this.button.focus(); + this.changeFileType.call(this, event); + _closeMenu(this); + break; + // Close menu and give focus to speed control. + case KEY.ESCAPE: + _closeMenu(this); + this.button.focus(); + break; + } + return false; + } + else { + switch(keyCode) { + // Open menu and focus on last element of list above it. + case KEY.ENTER: + case KEY.SPACE: + case KEY.UP: + _openMenu(this); + this.menuItemsLinks.last().focus(); + break; + // Close menu. + case KEY.ESCAPE: + _closeMenu(this); + break; + } + // We do not stop propagation and default behavior on a TAB + // keypress. + return event.keyCode === KEY.TAB; + } +    } + + /** + * @desc Bind any necessary function callbacks to DOM events (click, + * mousemove, etc.). + * + * @type {function} + * @access private + * + * @param {object} state The object containg the state of the video player. + * All other modules, their parameters, public variables, etc. are + * available via this object. + * + * @this {object} The global window object. + * + * @returns {undefined} + */ + function _bindHandlers(state) { + var menu = state.videoAccessibleMenu; + + // Attach various events handlers to menu container. + menu.container.on({ + 'mouseenter': _openMenuHandler.bind(menu), + 'mouseleave': _closeMenuHandler.bind(menu), + 'click': _toggleMenuHandler.bind(menu), + 'keydown': _keyDownHandler.bind(menu) + }); + + // Attach click and keydown event handlers to individual menu items. + menu.menuItems + .on('click', 'a.a11y-menu-item-link', _clickHandler.bind(menu)) + .on('keydown', 'a.a11y-menu-item-link', _keyDownHandler.bind(menu)); + } + + function setValue(value) { + var menu = this.videoAccessibleMenu; + + menu.value = value; + menu.menuItems + .removeClass('active') + .find("a[data-value='" + value + "']") + .parent() + .addClass('active'); + } + + // *************************************************************** + // Public functions start here. + // These are available via the 'state' object. Their context ('this' + // keyword) is the 'state' object. The magic private function that makes + // them available and sets up their context is makeFunctionsPublic(). + // *************************************************************** + + function changeFileType(event) { + var fileType = $(event.currentTarget).data('value'); + + this.videoAccessibleMenu.setValue(fileType); + this.saveState(true, {'transcript_download_format': fileType}); + this.storage.setItem('transcript_download_format', fileType); + } + +}); + +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/js/src/video/10_main.js b/common/lib/xmodule/xmodule/js/src/video/10_main.js index 774bee8640..2721cd2a43 100644 --- a/common/lib/xmodule/xmodule/js/src/video/10_main.js +++ b/common/lib/xmodule/xmodule/js/src/video/10_main.js @@ -42,6 +42,7 @@ require( [ 'video/01_initialize.js', 'video/025_focus_grabber.js', + 'video/035_video_accessible_menu.js', 'video/04_video_control.js', 'video/05_video_quality_control.js', 'video/06_video_progress_slider.js', @@ -52,6 +53,7 @@ require( function ( Initialize, FocusGrabber, + VideoAccessibleMenu, VideoControl, VideoQualityControl, VideoProgressSlider, @@ -87,6 +89,7 @@ function ( state.modules = [ FocusGrabber, + VideoAccessibleMenu, VideoControl, VideoQualityControl, VideoProgressSlider, diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py index afcc64887b..c60dc42dcc 100644 --- a/common/lib/xmodule/xmodule/video_module/video_module.py +++ b/common/lib/xmodule/xmodule/video_module/video_module.py @@ -13,6 +13,7 @@ in XML. import json import logging from operator import itemgetter +from HTMLParser import HTMLParser from lxml import etree from pkg_resources import resource_string @@ -155,6 +156,15 @@ class VideoFields(object): scope=Scope.preferences, default="en" ) + transcript_download_format = String( + help="Transcript file format to download by user.", + scope=Scope.preferences, + values=[ + {"display_name": "SubRip (.srt) file", "value": "srt"}, + {"display_name": "Text (.txt) file", "value": "txt"} + ], + default='srt', + ) speed = Float( help="The last speed that was explicitly set by user for the video.", scope=Scope.user_state, @@ -193,6 +203,7 @@ class VideoModule(VideoFields, XModule): resource_string(module, 'js/src/video/025_focus_grabber.js'), resource_string(module, 'js/src/video/02_html5_video.js'), resource_string(module, 'js/src/video/03_video_player.js'), + resource_string(module, 'js/src/video/035_video_accessible_menu.js'), resource_string(module, 'js/src/video/04_video_control.js'), resource_string(module, 'js/src/video/05_video_quality_control.js'), resource_string(module, 'js/src/video/06_video_progress_slider.js'), @@ -202,20 +213,33 @@ class VideoModule(VideoFields, XModule): resource_string(module, 'js/src/video/10_main.js') ] } - css = {'scss': [resource_string(module, 'css/video/display.scss')]} + css = {'scss': [ + resource_string(module, 'css/video/display.scss'), + resource_string(module, 'css/video/accessible_menu.scss'), + ]} js_module_name = "Video" def handle_ajax(self, dispatch, data): - accepted_keys = ['speed', 'saved_video_position', 'transcript_language'] - if dispatch == 'save_user_state': + accepted_keys = [ + 'speed', 'saved_video_position', 'transcript_language', + 'transcript_download_format', + ] + conversions = { + 'speed': json.loads, + 'saved_video_position': lambda v: RelativeTime.isotime_to_timedelta(v), + } + + if dispatch == 'save_user_state': for key in data: if hasattr(self, key) and key in accepted_keys: - if key == 'saved_video_position': - relative_position = RelativeTime.isotime_to_timedelta(data[key]) - self.saved_video_position = relative_position + if key in conversions: + value = conversions[key](data[key]) else: - setattr(self, key, json.loads(data[key])) + value = data[key] + + setattr(self, key, value) + if key == 'speed': self.global_speed = self.speed @@ -228,6 +252,7 @@ class VideoModule(VideoFields, XModule): def get_html(self): track_url = None + transcript_download_format = self.transcript_download_format get_ext = lambda filename: filename.rpartition('.')[-1] sources = {get_ext(src): src for src in self.html5_sources} @@ -241,7 +266,8 @@ class VideoModule(VideoFields, XModule): if self.download_track: if self.track: track_url = self.track - elif self.sub: + transcript_download_format = None + elif self.sub or self.transcripts: track_url = self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/download' if not self.transcripts: @@ -289,13 +315,15 @@ class VideoModule(VideoFields, XModule): # configuration setting field. 'yt_test_timeout': 1500, 'yt_test_url': settings.YOUTUBE_TEST_URL, + 'transcript_download_format': transcript_download_format, + 'transcript_download_formats_list': self.descriptor.fields['transcript_download_format'].values, 'transcript_language': transcript_language, 'transcript_languages': json.dumps(sorted_languages), 'transcript_translation_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/translation', 'transcript_available_translations_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/available_translations', }) - def get_transcript(self): + def get_transcript(self, format='srt'): """ Returns transcript in *.srt format. @@ -307,12 +335,18 @@ class VideoModule(VideoFields, XModule): lang = self.transcript_language subs_id = self.sub if lang == 'en' else self.youtube_id_1_0 data = asset(self.location, subs_id, lang).data - str_subs = generate_srt_from_sjson(json.loads(data), speed=1.0) + if format == 'txt': + text = json.loads(data)['text'] + str_subs = HTMLParser().unescape("\n".join(text)) + mime_type = 'text/plain' + else: + str_subs = generate_srt_from_sjson(json.loads(data), speed=1.0) + mime_type = 'application/x-subrip' if not str_subs: log.debug('generate_srt_from_sjson produces no subtitles') raise ValueError - return str_subs + return str_subs, format, mime_type @XBlock.handler def transcript(self, request, dispatch): @@ -350,7 +384,7 @@ class VideoModule(VideoFields, XModule): elif dispatch == 'download': try: - subs = self.get_transcript() + subs, format, mime_type = self.get_transcript(format=self.transcript_download_format) except (NotFoundError, ValueError, KeyError): log.debug("Video@download exception") response = Response(status=404) @@ -358,10 +392,13 @@ class VideoModule(VideoFields, XModule): response = Response( subs, headerlist=[ - ('Content-Disposition', 'attachment; filename="{0}.srt"'.format(self.transcript_language)), + ('Content-Disposition', 'attachment; filename="{filename}.{format}"'.format( + filename=self.transcript_language, + format=format, + )), ] ) - response.content_type = "application/x-subrip" + response.content_type = mime_type elif dispatch == 'available_translations': available_translations = [] diff --git a/common/test/data/uploads/subs_OEoXaMPEzfM.srt.sjson b/common/test/data/uploads/subs_OEoXaMPEzfM.srt.sjson index c07fa0636f..3c62b7f76e 100644 --- a/common/test/data/uploads/subs_OEoXaMPEzfM.srt.sjson +++ b/common/test/data/uploads/subs_OEoXaMPEzfM.srt.sjson @@ -94,7 +94,7 @@ 114220 ], "text": [ - "LILA FISHER: Hi, welcome to Edx.", + "Hi, welcome to Edx.", "I'm Lila Fisher, an Edx fellow helping to put", "together these courses.", "As you know, our courses are entirely online.", diff --git a/lms/djangoapps/courseware/features/video.feature b/lms/djangoapps/courseware/features/video.feature index 55b89ae165..721e4630a7 100644 --- a/lms/djangoapps/courseware/features/video.feature +++ b/lms/djangoapps/courseware/features/video.feature @@ -155,3 +155,24 @@ Feature: LMS Video component Then I see video aligned correctly with enabled transcript And I click video button "CC" Then I see video aligned correctly without enabled transcript + + # 19 + Scenario: Download Transcript button works correctly in Video component + Given I am registered for the course "test_course" + And it has a video "A" in "Youtube" mode in position "1" of sequential: + | sub | download_track | + | OEoXaMPEzfM | true | + And a video "B" in "Youtube" mode in position "2" of sequential: + | sub | download_track | + | OEoXaMPEzfM | true | + And a video "C" in "Youtube" mode in position "3" of sequential: + | track | download_track | + | http://example.org/ | true | + And I open the section with videos + And I can download transcript in "srt" format + And I select the transcript format "txt" + And I can download transcript in "txt" format + When I open video "B" + Then I can download transcript in "txt" format + When I open video "C" + Then menu "download_transcript" doesn't exist diff --git a/lms/djangoapps/courseware/features/video.py b/lms/djangoapps/courseware/features/video.py index c1b221f8eb..2cd36b5641 100644 --- a/lms/djangoapps/courseware/features/video.py +++ b/lms/djangoapps/courseware/features/video.py @@ -3,12 +3,13 @@ from lettuce import world, step import json +import os +import requests from common import i_am_registered_for_the_course, section_location, visit_scenario_item from django.utils.translation import ugettext as _ from django.conf import settings from cache_toolbox.core import del_cached_content from xmodule.contentstore.content import StaticContent -import os from xmodule.contentstore.django import contentstore TEST_ROOT = settings.COMMON_TEST_DATA_ROOT LANGUAGES = settings.ALL_LANGUAGES @@ -32,17 +33,57 @@ VIDEO_BUTTONS = { 'play': '.video_control.play', 'pause': '.video_control.pause', 'fullscreen': '.add-fullscreen', + 'download_transcript': '.video-tracks > a', } VIDEO_MENUS = { 'language': '.lang .menu', 'speed': '.speed .menu', + 'download_transcript': '.video-tracks .a11y-menu-list', } coursenum = 'test_course' sequence = {} +class ReuqestHandlerWithSessionId(object): + def get(self, url): + """ + Sends a request. + """ + kwargs = dict() + + session_id = [{i['name']:i['value']} for i in world.browser.cookies.all() if i['name']==u'sessionid'] + if session_id: + kwargs.update({ + 'cookies': session_id[0] + }) + + response = requests.get(url, **kwargs) + self.response = response + self.status_code = response.status_code + self.headers = response.headers + self.content = response.content + + return self + + def is_success(self): + """ + Returns `True` if the response was succeed, otherwise, returns `False`. + """ + if self.status_code < 400: + return True + return False + + def check_header(self, name, value): + """ + Returns `True` if the response header exist and has appropriate value, + otherwise, returns `False`. + """ + if value in self.headers.get(name, ''): + return True + return False + def add_video_to_course(course, player_mode, hashes, display_name='Video'): category = 'video' @@ -80,15 +121,23 @@ def add_video_to_course(course, player_mode, hashes, display_name='Video'): if hashes: kwargs['metadata'].update(hashes[0]) - course_location = world.scenario_dict['COURSE'].location + course_location =world.scenario_dict['COURSE'].location + + conversions = { + 'transcripts': json.loads, + 'download_track': json.loads, + 'download_video': json.loads, + } + + for key in kwargs['metadata']: + if key in conversions: + kwargs['metadata'][key] = conversions[key](kwargs['metadata'][key]) if 'sub' in kwargs['metadata']: filename = _get_sjson_filename(kwargs['metadata']['sub'], 'en') _upload_file(filename, course_location) if 'transcripts' in kwargs['metadata']: - kwargs['metadata']['transcripts'] = json.loads(kwargs['metadata']['transcripts']) - for lang, filename in kwargs['metadata']['transcripts'].items(): _upload_file(filename, course_location) @@ -322,6 +371,10 @@ def upload_to_assets(_step, filename): def is_hidden_button(_step, button): assert not world.css_visible(VIDEO_BUTTONS[button]) +@step('menu "([^"]*)" doesn\'t exist$') +def is_hidden_menu(_step, menu): + assert world.is_css_not_present(VIDEO_MENUS[menu]) + @step('I see video aligned correctly (with(?:out)?) enabled transcript$') def video_alignment(_step, transcript_visibility): @@ -345,3 +398,45 @@ def video_alignment(_step, transcript_visibility): ) assert all([width, height]) + + +@step('I can download transcript in "([^"]*)" format$') +def i_can_download_transcript(_step, format): + button = world.css_find('.video-tracks .a11y-menu-button').first + assert button.text.strip() == '.' + format + + formats = { + 'srt': { + 'content': '0\n00:00:00,270', + 'mime_type': 'application/x-subrip' + }, + 'txt': { + 'content': 'Hi, welcome to Edx.', + 'mime_type': 'text/plain' + }, + } + + url = world.css_find(VIDEO_BUTTONS['download_transcript'])[0]['href'] + request = ReuqestHandlerWithSessionId() + assert request.get(url).is_success() + assert request.check_header('content-type', formats[format]['mime_type']) + assert request.content.startswith(formats[format]['content']) + + +@step('I select the transcript format "([^"]*)"$') +def select_transcript_format(_step, format): + button = world.css_find('.video-tracks .a11y-menu-button').first + button.mouse_over() + assert button.text.strip() == '...' + + menu_selector = VIDEO_MENUS['download_transcript'] + menu_items = world.css_find(menu_selector + ' a') + + for item in menu_items: + if item['data-value'] == format: + item.click() + world.wait_for_ajax_complete() + break + + assert world.css_find(menu_selector + ' .active a')[0]['data-value'] == format + assert button.text.strip() == '.' + format diff --git a/lms/djangoapps/courseware/tests/test_video_handlers.py b/lms/djangoapps/courseware/tests/test_video_handlers.py index 190a06acf8..7447fb5b82 100644 --- a/lms/djangoapps/courseware/tests/test_video_handlers.py +++ b/lms/djangoapps/courseware/tests/test_video_handlers.py @@ -110,7 +110,7 @@ class TestVideo(BaseTestXmodule): data = [ {'speed': 2.0}, {'saved_video_position': "00:00:10"}, - {'transcript_language': json.dumps('uk')}, + {'transcript_language': 'uk'}, ] for sample in data: response = self.clients[self.users[0].username].post( @@ -129,7 +129,7 @@ class TestVideo(BaseTestXmodule): self.assertEqual(self.item_descriptor.saved_video_position, timedelta(0, 10)) self.assertEqual(self.item_descriptor.transcript_language, 'en') - self.item_descriptor.handle_ajax('save_user_state', {'transcript_language': json.dumps("uk")}) + self.item_descriptor.handle_ajax('save_user_state', {'transcript_language': "uk"}) self.assertEqual(self.item_descriptor.transcript_language, 'uk') def tearDown(self): @@ -173,11 +173,20 @@ class TestVideoTranscriptTranslation(TestVideo): response = self.item.transcript(request=request, dispatch='download') self.assertEqual(response.status, '404 Not Found') - @patch('xmodule.video_module.VideoModule.get_transcript', return_value='Subs!') - def test_download_exist(self, __): + @patch('xmodule.video_module.VideoModule.get_transcript', return_value=('Subs!', 'srt', 'application/x-subrip')) + def test_download_srt_exist(self, __): request = Request.blank('/download?language=en') response = self.item.transcript(request=request, dispatch='download') self.assertEqual(response.body, 'Subs!') + self.assertEqual(response.headers['Content-Type'], 'application/x-subrip') + + @patch('xmodule.video_module.VideoModule.get_transcript', return_value=('Subs!', 'txt', 'text/plain')) + def test_download_txt_exist(self, __): + self.item.transcript_format = 'txt' + request = Request.blank('/download?language=en') + response = self.item.transcript(request=request, dispatch='download') + self.assertEqual(response.body, 'Subs!') + self.assertEqual(response.headers['Content-Type'], 'text/plain') def test_download_en_no_sub(self): request = Request.blank('/download?language=en') @@ -309,7 +318,7 @@ class TestVideoTranscriptsDownload(TestVideo): self.item_descriptor.render('student_view') self.item = self.item_descriptor.xmodule_runtime.xmodule_instance - def test_good_transcript(self): + def test_good_srt_transcript(self): good_sjson = _create_file(content=textwrap.dedent("""\ { "start": [ @@ -329,7 +338,7 @@ class TestVideoTranscriptsDownload(TestVideo): _upload_sjson_file(good_sjson, self.item.location) self.item.sub = _get_subs_id(good_sjson.name) - text = self.item.get_transcript() + text, format, download = self.item.get_transcript() expected_text = textwrap.dedent("""\ 0 00:00:00,270 --> 00:00:02,720 @@ -343,6 +352,33 @@ class TestVideoTranscriptsDownload(TestVideo): self.assertEqual(text, expected_text) + def test_good_txt_transcript(self): + good_sjson = _create_file(content=textwrap.dedent("""\ + { + "start": [ + 270, + 2720 + ], + "end": [ + 2720, + 5430 + ], + "text": [ + "Hi, welcome to Edx.", + "Let's start with what is on your screen right now." + ] + } + """)) + + _upload_sjson_file(good_sjson, self.item.location) + self.item.sub = _get_subs_id(good_sjson.name) + text, format, mime_type = self.item.get_transcript(format="txt") + expected_text = textwrap.dedent("""\ + Hi, welcome to Edx. + Let's start with what is on your screen right now.""") + + self.assertEqual(text, expected_text) + def test_not_found_error(self): with self.assertRaises(NotFoundError): self.item.get_transcript() diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py index 5b74762b4a..38e69afa35 100644 --- a/lms/djangoapps/courseware/tests/test_video_mongo.py +++ b/lms/djangoapps/courseware/tests/test_video_mongo.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- """Video xmodule tests in mongo.""" from mock import patch, PropertyMock -import json from . import BaseTestXmodule from .test_video_xml import SOURCE_XML @@ -41,6 +40,8 @@ class TestVideoYouTube(TestVideo): 'youtube_streams': create_youtube_string(self.item_descriptor), 'yt_test_timeout': 1500, 'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', + 'transcript_download_format': 'srt', + 'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}], 'transcript_language': 'en', 'transcript_languages': '{"en": "English", "uk": "Ukrainian"}', 'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url( @@ -103,6 +104,8 @@ class TestVideoNonYouTube(TestVideo): 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'yt_test_timeout': 1500, 'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', + 'transcript_download_format': 'srt', + 'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}], 'transcript_language': 'en', 'transcript_languages': '{"en": "English"}', 'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url( @@ -191,6 +194,7 @@ class TestGetHtmlMethod(BaseTestXmodule): 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'yt_test_timeout': 1500, 'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', + 'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}], } for data in cases: @@ -208,6 +212,7 @@ class TestGetHtmlMethod(BaseTestXmodule): context = self.item_descriptor.render('student_view').content expected_context.update({ + 'transcript_download_format': None if self.item_descriptor.track and self.item_descriptor.download_track else 'srt', 'transcript_languages': '{"en": "English"}', 'transcript_language': 'en', 'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url( @@ -305,6 +310,8 @@ class TestGetHtmlMethod(BaseTestXmodule): 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'yt_test_timeout': 1500, 'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', + 'transcript_download_format': 'srt', + 'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}], 'transcript_language': 'en', 'transcript_languages': '{"en": "English"}', } diff --git a/lms/templates/video.html b/lms/templates/video.html index 423c5af709..6522a9417b 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -112,7 +112,29 @@ % endif % if track:
  • - ${('' + _('Download timed transcript') + '') % track} + % if transcript_download_format: + ${('' + _('Download transcript') + '') % track + } +
    + ${'.' + transcript_download_format} +
      + % for item in transcript_download_formats_list: + % if item['value'] == transcript_download_format: +
    1. + % else: +
    2. + % endif + + ${_('{file_format}'.format(file_format=item['display_name']))} + +
    3. + % endfor +
    +
    + % else: + ${('' + _('Download transcript') + '') % track + } + % endif
  • % endif