Merge pull request #2844 from edx/jmclaus/feature_download_transcript_button_with_options
Add .txt and .srt options to the "download transcript" button [BLD-844]
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
133
common/lib/xmodule/xmodule/css/video/accessible_menu.scss
Normal file
133
common/lib/xmodule/xmodule/css/video/accessible_menu.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,23 @@
|
||||
</div>
|
||||
|
||||
<div class="focus_grabber last"></div>
|
||||
|
||||
<ul class="wrapper-downloads">
|
||||
<li class="video-tracks">
|
||||
<div class="a11y-menu-container">
|
||||
<a class="a11y-menu-button" href="#" title=".srt">.srt</a>
|
||||
<ol class="a11y-menu-list">
|
||||
<li class="a11y-menu-item">
|
||||
<a class="a11y-menu-item-link" href="#txt" title="Text (.txt) file" data-value="txt">Text (.txt) file</a>
|
||||
</li>
|
||||
<li class="a11y-menu-item active">
|
||||
<a class="a11y-menu-item-link" href="#srt" title="SubRip (.srt) file" data-value="srt">SubRip (.srt) file</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
@@ -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 <div class='menu-container'></div> 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));
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"}',
|
||||
}
|
||||
|
||||
@@ -112,7 +112,29 @@
|
||||
% endif
|
||||
% if track:
|
||||
<li class="video-tracks">
|
||||
${('<a href="%s">' + _('Download timed transcript') + '</a>') % track}
|
||||
% if transcript_download_format:
|
||||
${('<a href="%s">' + _('Download transcript') + '</a>') % track
|
||||
}
|
||||
<div class="a11y-menu-container">
|
||||
<a class="a11y-menu-button" href="#" title="${'.' + transcript_download_format}">${'.' + transcript_download_format}</a>
|
||||
<ol class="a11y-menu-list">
|
||||
% for item in transcript_download_formats_list:
|
||||
% if item['value'] == transcript_download_format:
|
||||
<li class="a11y-menu-item active">
|
||||
% else:
|
||||
<li class="a11y-menu-item">
|
||||
% endif
|
||||
<a class="a11y-menu-item-link" href="#${item['value']}" title="${_('{file_format}'.format(file_format=item['display_name']))}" data-value="${item['value']}">
|
||||
${_('{file_format}'.format(file_format=item['display_name']))}
|
||||
</a>
|
||||
</li>
|
||||
% endfor
|
||||
</ol>
|
||||
</div>
|
||||
% else:
|
||||
${('<a href="%s" class="external-track">' + _('Download transcript') + '</a>') % track
|
||||
}
|
||||
% endif
|
||||
</li>
|
||||
% endif
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user