Commit 3a740c04 by jmclaus

BLD-844: Add possibility to download transcripts in different formats.

parent 2c9585ea
...@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -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 in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. 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: 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. Blades: Show start time or starting position on slider and VCR. BLD-823.
......
...@@ -22,6 +22,15 @@ ...@@ -22,6 +22,15 @@
.video-controls .add-fullscreen { .video-controls .add-fullscreen {
display: none !important; // nasty, but needed to override the bad specificity of the xmodule css selectors 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;
}
}
}
} }
} }
......
$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 { ...@@ -46,13 +46,15 @@ div.video {
.video-sources, .video-sources,
.video-tracks { .video-tracks {
display: inline-block; display: inline-block;
vertical-align: top;
margin: ($baseline*.75) ($baseline/2) 0 0; margin: ($baseline*.75) ($baseline/2) 0 0;
a { > a {
@include transition(all 0.25s ease-in-out 0s); @include transition(all 0.25s ease-in-out 0s);
@include font-size(14); @include font-size(14);
display: inline-block; line-height : 14px;
border-radius: 3px 3px 3px 3px; float: left;
border-radius: 3px;
background-color: $very-light-text; background-color: $very-light-text;
padding: ($baseline*.75); padding: ($baseline*.75);
color: $lighter-base-font-color; color: $lighter-base-font-color;
...@@ -62,7 +64,14 @@ div.video { ...@@ -62,7 +64,14 @@ div.video {
color: $very-light-text; color: $very-light-text;
} }
} }
}
.video-tracks {
> a {
border-radius: 3px 0 0 3px;
}
> a.external-track {
border-radius: 3px;
}
} }
} }
......
...@@ -69,6 +69,23 @@ ...@@ -69,6 +69,23 @@
</div> </div>
<div class="focus_grabber last"></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> </div>
</div> </div>
......
(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);
(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( ...@@ -42,6 +42,7 @@ require(
[ [
'video/01_initialize.js', 'video/01_initialize.js',
'video/025_focus_grabber.js', 'video/025_focus_grabber.js',
'video/035_video_accessible_menu.js',
'video/04_video_control.js', 'video/04_video_control.js',
'video/05_video_quality_control.js', 'video/05_video_quality_control.js',
'video/06_video_progress_slider.js', 'video/06_video_progress_slider.js',
...@@ -52,6 +53,7 @@ require( ...@@ -52,6 +53,7 @@ require(
function ( function (
Initialize, Initialize,
FocusGrabber, FocusGrabber,
VideoAccessibleMenu,
VideoControl, VideoControl,
VideoQualityControl, VideoQualityControl,
VideoProgressSlider, VideoProgressSlider,
...@@ -87,6 +89,7 @@ function ( ...@@ -87,6 +89,7 @@ function (
state.modules = [ state.modules = [
FocusGrabber, FocusGrabber,
VideoAccessibleMenu,
VideoControl, VideoControl,
VideoQualityControl, VideoQualityControl,
VideoProgressSlider, VideoProgressSlider,
......
...@@ -13,6 +13,7 @@ in XML. ...@@ -13,6 +13,7 @@ in XML.
import json import json
import logging import logging
from operator import itemgetter from operator import itemgetter
from HTMLParser import HTMLParser
from lxml import etree from lxml import etree
from pkg_resources import resource_string from pkg_resources import resource_string
...@@ -155,6 +156,15 @@ class VideoFields(object): ...@@ -155,6 +156,15 @@ class VideoFields(object):
scope=Scope.preferences, scope=Scope.preferences,
default="en" 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( speed = Float(
help="The last speed that was explicitly set by user for the video.", help="The last speed that was explicitly set by user for the video.",
scope=Scope.user_state, scope=Scope.user_state,
...@@ -193,6 +203,7 @@ class VideoModule(VideoFields, XModule): ...@@ -193,6 +203,7 @@ class VideoModule(VideoFields, XModule):
resource_string(module, 'js/src/video/025_focus_grabber.js'), 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/02_html5_video.js'),
resource_string(module, 'js/src/video/03_video_player.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/04_video_control.js'),
resource_string(module, 'js/src/video/05_video_quality_control.js'), resource_string(module, 'js/src/video/05_video_quality_control.js'),
resource_string(module, 'js/src/video/06_video_progress_slider.js'), resource_string(module, 'js/src/video/06_video_progress_slider.js'),
...@@ -202,20 +213,33 @@ class VideoModule(VideoFields, XModule): ...@@ -202,20 +213,33 @@ class VideoModule(VideoFields, XModule):
resource_string(module, 'js/src/video/10_main.js') 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" js_module_name = "Video"
def handle_ajax(self, dispatch, data): def handle_ajax(self, dispatch, data):
accepted_keys = ['speed', 'saved_video_position', 'transcript_language'] accepted_keys = [
if dispatch == 'save_user_state': '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: for key in data:
if hasattr(self, key) and key in accepted_keys: if hasattr(self, key) and key in accepted_keys:
if key == 'saved_video_position': if key in conversions:
relative_position = RelativeTime.isotime_to_timedelta(data[key]) value = conversions[key](data[key])
self.saved_video_position = relative_position
else: else:
setattr(self, key, json.loads(data[key])) value = data[key]
setattr(self, key, value)
if key == 'speed': if key == 'speed':
self.global_speed = self.speed self.global_speed = self.speed
...@@ -228,6 +252,7 @@ class VideoModule(VideoFields, XModule): ...@@ -228,6 +252,7 @@ class VideoModule(VideoFields, XModule):
def get_html(self): def get_html(self):
track_url = None track_url = None
transcript_download_format = self.transcript_download_format
get_ext = lambda filename: filename.rpartition('.')[-1] get_ext = lambda filename: filename.rpartition('.')[-1]
sources = {get_ext(src): src for src in self.html5_sources} sources = {get_ext(src): src for src in self.html5_sources}
...@@ -241,7 +266,8 @@ class VideoModule(VideoFields, XModule): ...@@ -241,7 +266,8 @@ class VideoModule(VideoFields, XModule):
if self.download_track: if self.download_track:
if self.track: if self.track:
track_url = 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' track_url = self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/download'
if not self.transcripts: if not self.transcripts:
...@@ -289,13 +315,15 @@ class VideoModule(VideoFields, XModule): ...@@ -289,13 +315,15 @@ class VideoModule(VideoFields, XModule):
# configuration setting field. # configuration setting field.
'yt_test_timeout': 1500, 'yt_test_timeout': 1500,
'yt_test_url': settings.YOUTUBE_TEST_URL, '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_language': transcript_language,
'transcript_languages': json.dumps(sorted_languages), 'transcript_languages': json.dumps(sorted_languages),
'transcript_translation_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/translation', 'transcript_translation_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/translation',
'transcript_available_translations_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/available_translations', '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. Returns transcript in *.srt format.
...@@ -307,12 +335,18 @@ class VideoModule(VideoFields, XModule): ...@@ -307,12 +335,18 @@ class VideoModule(VideoFields, XModule):
lang = self.transcript_language lang = self.transcript_language
subs_id = self.sub if lang == 'en' else self.youtube_id_1_0 subs_id = self.sub if lang == 'en' else self.youtube_id_1_0
data = asset(self.location, subs_id, lang).data data = asset(self.location, subs_id, lang).data
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) str_subs = generate_srt_from_sjson(json.loads(data), speed=1.0)
mime_type = 'application/x-subrip'
if not str_subs: if not str_subs:
log.debug('generate_srt_from_sjson produces no subtitles') log.debug('generate_srt_from_sjson produces no subtitles')
raise ValueError raise ValueError
return str_subs return str_subs, format, mime_type
@XBlock.handler @XBlock.handler
def transcript(self, request, dispatch): def transcript(self, request, dispatch):
...@@ -350,7 +384,7 @@ class VideoModule(VideoFields, XModule): ...@@ -350,7 +384,7 @@ class VideoModule(VideoFields, XModule):
elif dispatch == 'download': elif dispatch == 'download':
try: try:
subs = self.get_transcript() subs, format, mime_type = self.get_transcript(format=self.transcript_download_format)
except (NotFoundError, ValueError, KeyError): except (NotFoundError, ValueError, KeyError):
log.debug("Video@download exception") log.debug("Video@download exception")
response = Response(status=404) response = Response(status=404)
...@@ -358,10 +392,13 @@ class VideoModule(VideoFields, XModule): ...@@ -358,10 +392,13 @@ class VideoModule(VideoFields, XModule):
response = Response( response = Response(
subs, subs,
headerlist=[ 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': elif dispatch == 'available_translations':
available_translations = [] available_translations = []
......
...@@ -94,7 +94,7 @@ ...@@ -94,7 +94,7 @@
114220 114220
], ],
"text": [ "text": [
"LILA FISHER: Hi, welcome to Edx.", "Hi, welcome to Edx.",
"I'm Lila Fisher, an Edx fellow helping to put", "I'm Lila Fisher, an Edx fellow helping to put",
"together these courses.", "together these courses.",
"As you know, our courses are entirely online.", "As you know, our courses are entirely online.",
......
...@@ -155,3 +155,24 @@ Feature: LMS Video component ...@@ -155,3 +155,24 @@ Feature: LMS Video component
Then I see video aligned correctly with enabled transcript Then I see video aligned correctly with enabled transcript
And I click video button "CC" And I click video button "CC"
Then I see video aligned correctly without enabled transcript 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 @@ ...@@ -3,12 +3,13 @@
from lettuce import world, step from lettuce import world, step
import json import json
import os
import requests
from common import i_am_registered_for_the_course, section_location, visit_scenario_item from common import i_am_registered_for_the_course, section_location, visit_scenario_item
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.conf import settings from django.conf import settings
from cache_toolbox.core import del_cached_content from cache_toolbox.core import del_cached_content
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
import os
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
LANGUAGES = settings.ALL_LANGUAGES LANGUAGES = settings.ALL_LANGUAGES
...@@ -32,17 +33,57 @@ VIDEO_BUTTONS = { ...@@ -32,17 +33,57 @@ VIDEO_BUTTONS = {
'play': '.video_control.play', 'play': '.video_control.play',
'pause': '.video_control.pause', 'pause': '.video_control.pause',
'fullscreen': '.add-fullscreen', 'fullscreen': '.add-fullscreen',
'download_transcript': '.video-tracks > a',
} }
VIDEO_MENUS = { VIDEO_MENUS = {
'language': '.lang .menu', 'language': '.lang .menu',
'speed': '.speed .menu', 'speed': '.speed .menu',
'download_transcript': '.video-tracks .a11y-menu-list',
} }
coursenum = 'test_course' coursenum = 'test_course'
sequence = {} 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'): def add_video_to_course(course, player_mode, hashes, display_name='Video'):
category = 'video' category = 'video'
...@@ -80,15 +121,23 @@ def add_video_to_course(course, player_mode, hashes, display_name='Video'): ...@@ -80,15 +121,23 @@ def add_video_to_course(course, player_mode, hashes, display_name='Video'):
if hashes: if hashes:
kwargs['metadata'].update(hashes[0]) 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']: if 'sub' in kwargs['metadata']:
filename = _get_sjson_filename(kwargs['metadata']['sub'], 'en') filename = _get_sjson_filename(kwargs['metadata']['sub'], 'en')
_upload_file(filename, course_location) _upload_file(filename, course_location)
if 'transcripts' in kwargs['metadata']: if 'transcripts' in kwargs['metadata']:
kwargs['metadata']['transcripts'] = json.loads(kwargs['metadata']['transcripts'])
for lang, filename in kwargs['metadata']['transcripts'].items(): for lang, filename in kwargs['metadata']['transcripts'].items():
_upload_file(filename, course_location) _upload_file(filename, course_location)
...@@ -322,6 +371,10 @@ def upload_to_assets(_step, filename): ...@@ -322,6 +371,10 @@ def upload_to_assets(_step, filename):
def is_hidden_button(_step, button): def is_hidden_button(_step, button):
assert not world.css_visible(VIDEO_BUTTONS[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$') @step('I see video aligned correctly (with(?:out)?) enabled transcript$')
def video_alignment(_step, transcript_visibility): def video_alignment(_step, transcript_visibility):
...@@ -345,3 +398,45 @@ def video_alignment(_step, transcript_visibility): ...@@ -345,3 +398,45 @@ def video_alignment(_step, transcript_visibility):
) )
assert all([width, height]) 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): ...@@ -110,7 +110,7 @@ class TestVideo(BaseTestXmodule):
data = [ data = [
{'speed': 2.0}, {'speed': 2.0},
{'saved_video_position': "00:00:10"}, {'saved_video_position': "00:00:10"},
{'transcript_language': json.dumps('uk')}, {'transcript_language': 'uk'},
] ]
for sample in data: for sample in data:
response = self.clients[self.users[0].username].post( response = self.clients[self.users[0].username].post(
...@@ -129,7 +129,7 @@ class TestVideo(BaseTestXmodule): ...@@ -129,7 +129,7 @@ class TestVideo(BaseTestXmodule):
self.assertEqual(self.item_descriptor.saved_video_position, timedelta(0, 10)) self.assertEqual(self.item_descriptor.saved_video_position, timedelta(0, 10))
self.assertEqual(self.item_descriptor.transcript_language, 'en') 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') self.assertEqual(self.item_descriptor.transcript_language, 'uk')
def tearDown(self): def tearDown(self):
...@@ -173,11 +173,20 @@ class TestVideoTranscriptTranslation(TestVideo): ...@@ -173,11 +173,20 @@ class TestVideoTranscriptTranslation(TestVideo):
response = self.item.transcript(request=request, dispatch='download') response = self.item.transcript(request=request, dispatch='download')
self.assertEqual(response.status, '404 Not Found') self.assertEqual(response.status, '404 Not Found')
@patch('xmodule.video_module.VideoModule.get_transcript', return_value='Subs!') @patch('xmodule.video_module.VideoModule.get_transcript', return_value=('Subs!', 'srt', 'application/x-subrip'))
def test_download_exist(self, __): def test_download_srt_exist(self, __):
request = Request.blank('/download?language=en') request = Request.blank('/download?language=en')
response = self.item.transcript(request=request, dispatch='download') response = self.item.transcript(request=request, dispatch='download')
self.assertEqual(response.body, 'Subs!') 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): def test_download_en_no_sub(self):
request = Request.blank('/download?language=en') request = Request.blank('/download?language=en')
...@@ -309,7 +318,7 @@ class TestVideoTranscriptsDownload(TestVideo): ...@@ -309,7 +318,7 @@ class TestVideoTranscriptsDownload(TestVideo):
self.item_descriptor.render('student_view') self.item_descriptor.render('student_view')
self.item = self.item_descriptor.xmodule_runtime.xmodule_instance 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("""\ good_sjson = _create_file(content=textwrap.dedent("""\
{ {
"start": [ "start": [
...@@ -329,7 +338,7 @@ class TestVideoTranscriptsDownload(TestVideo): ...@@ -329,7 +338,7 @@ class TestVideoTranscriptsDownload(TestVideo):
_upload_sjson_file(good_sjson, self.item.location) _upload_sjson_file(good_sjson, self.item.location)
self.item.sub = _get_subs_id(good_sjson.name) 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("""\ expected_text = textwrap.dedent("""\
0 0
00:00:00,270 --> 00:00:02,720 00:00:00,270 --> 00:00:02,720
...@@ -343,6 +352,33 @@ class TestVideoTranscriptsDownload(TestVideo): ...@@ -343,6 +352,33 @@ class TestVideoTranscriptsDownload(TestVideo):
self.assertEqual(text, expected_text) 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&#39;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): def test_not_found_error(self):
with self.assertRaises(NotFoundError): with self.assertRaises(NotFoundError):
self.item.get_transcript() self.item.get_transcript()
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Video xmodule tests in mongo.""" """Video xmodule tests in mongo."""
from mock import patch, PropertyMock from mock import patch, PropertyMock
import json
from . import BaseTestXmodule from . import BaseTestXmodule
from .test_video_xml import SOURCE_XML from .test_video_xml import SOURCE_XML
...@@ -41,6 +40,8 @@ class TestVideoYouTube(TestVideo): ...@@ -41,6 +40,8 @@ class TestVideoYouTube(TestVideo):
'youtube_streams': create_youtube_string(self.item_descriptor), 'youtube_streams': create_youtube_string(self.item_descriptor),
'yt_test_timeout': 1500, 'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', '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_language': 'en',
'transcript_languages': '{"en": "English", "uk": "Ukrainian"}', 'transcript_languages': '{"en": "English", "uk": "Ukrainian"}',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url( 'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
...@@ -103,6 +104,8 @@ class TestVideoNonYouTube(TestVideo): ...@@ -103,6 +104,8 @@ class TestVideoNonYouTube(TestVideo):
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500, 'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', '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_language': 'en',
'transcript_languages': '{"en": "English"}', 'transcript_languages': '{"en": "English"}',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url( 'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
...@@ -191,6 +194,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -191,6 +194,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500, 'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', '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: for data in cases:
...@@ -208,6 +212,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -208,6 +212,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
context = self.item_descriptor.render('student_view').content context = self.item_descriptor.render('student_view').content
expected_context.update({ 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_languages': '{"en": "English"}',
'transcript_language': 'en', 'transcript_language': 'en',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url( 'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
...@@ -305,6 +310,8 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -305,6 +310,8 @@ class TestGetHtmlMethod(BaseTestXmodule):
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500, 'yt_test_timeout': 1500,
'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', '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_language': 'en',
'transcript_languages': '{"en": "English"}', 'transcript_languages': '{"en": "English"}',
} }
......
...@@ -112,7 +112,29 @@ ...@@ -112,7 +112,29 @@
% endif % endif
% if track: % if track:
<li class="video-tracks"> <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> </li>
% endif % endif
</ul> </ul>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment