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
+ }
+
+ % else:
+ ${('' + _('Download transcript') + '') % track
+ }
+ % endif
% endif