* fix: eslint operator-linebreak issue * fix: eslint quotes issue * fix: react jsx indent and props issues * fix: eslint trailing spaces issues * fix: eslint line around directives issue * fix: eslint semi rule * fix: eslint newline per chain rule * fix: eslint space infix ops rule * fix: eslint space-in-parens issue * fix: eslint space before function paren issue * fix: eslint space before blocks issue * fix: eslint arrow body style issue * fix: eslint dot-location issue * fix: eslint quotes issue * fix: eslint quote props issue * fix: eslint operator assignment issue * fix: eslint new line after import issue * fix: indent issues * fix: operator assignment issue * fix: all autofixable eslint issues * fix: all react related fixable issues * fix: autofixable eslint issues * chore: remove all template literals * fix: remaining autofixable issues * chore: apply amnesty on all existing issues * fix: failing xss-lint issues * refactor: apply amnesty on remaining issues * refactor: apply amnesty on new issues * fix: remove file level suppressions * refactor: apply amnesty on new issues
1387 lines
53 KiB
JavaScript
1387 lines
53 KiB
JavaScript
(function(define) {
|
|
// VideoCaption module.
|
|
|
|
'use strict';
|
|
|
|
define('video/09_video_caption.js', [
|
|
'video/00_sjson.js',
|
|
'video/00_async_process.js',
|
|
'edx-ui-toolkit/js/utils/html-utils',
|
|
'draggabilly',
|
|
'time.js',
|
|
'underscore'
|
|
], function(Sjson, AsyncProcess, HtmlUtils, Draggabilly, Time, _) {
|
|
/**
|
|
* @desc VideoCaption module exports a function.
|
|
*
|
|
* @type {function}
|
|
* @access public
|
|
*
|
|
* @param {object} state - The object containing 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 {jquery Promise}
|
|
*/
|
|
var VideoCaption = function(state) {
|
|
if (!(this instanceof VideoCaption)) {
|
|
return new VideoCaption(state);
|
|
}
|
|
|
|
_.bindAll(this, 'toggleTranscript', 'onMouseEnter', 'onMouseLeave', 'onMovement',
|
|
'onContainerMouseEnter', 'onContainerMouseLeave', 'fetchCaption',
|
|
'onResize', 'pause', 'play', 'onCaptionUpdate', 'onCaptionHandler', 'destroy',
|
|
'handleKeypress', 'handleKeypressLink', 'openLanguageMenu', 'closeLanguageMenu',
|
|
'previousLanguageMenuItem', 'nextLanguageMenuItem', 'handleCaptionToggle',
|
|
'showClosedCaptions', 'hideClosedCaptions', 'toggleClosedCaptions',
|
|
'updateCaptioningCookie', 'handleCaptioningCookie', 'handleTranscriptToggle',
|
|
'listenForDragDrop', 'setTranscriptVisibility', 'updateTranscriptCookie'
|
|
);
|
|
|
|
this.state = state;
|
|
this.state.videoCaption = this;
|
|
this.renderElements();
|
|
this.handleCaptioningCookie();
|
|
this.setTranscriptVisibility();
|
|
this.listenForDragDrop();
|
|
|
|
return $.Deferred().resolve().promise();
|
|
};
|
|
|
|
VideoCaption.prototype = {
|
|
|
|
destroy: function() {
|
|
this.state.el
|
|
.off({
|
|
'caption:fetch': this.fetchCaption,
|
|
'caption:resize': this.onResize,
|
|
'caption:update': this.onCaptionUpdate,
|
|
ended: this.pause,
|
|
fullscreen: this.onResize,
|
|
pause: this.pause,
|
|
play: this.play,
|
|
destroy: this.destroy
|
|
})
|
|
.removeClass('is-captions-rendered');
|
|
if (this.fetchXHR && this.fetchXHR.abort) {
|
|
this.fetchXHR.abort();
|
|
}
|
|
if (this.availableTranslationsXHR && this.availableTranslationsXHR.abort) {
|
|
this.availableTranslationsXHR.abort();
|
|
}
|
|
this.subtitlesEl.remove();
|
|
this.container.remove();
|
|
delete this.state.videoCaption;
|
|
},
|
|
/**
|
|
* @desc Initiate rendering of elements, and set their initial configuration.
|
|
*
|
|
*/
|
|
renderElements: function() {
|
|
var languages = this.state.config.transcriptLanguages;
|
|
|
|
var langHtml = HtmlUtils.interpolateHtml(
|
|
HtmlUtils.HTML(
|
|
[
|
|
'<div class="grouped-controls">',
|
|
'<button class="control toggle-captions" aria-disabled="false">',
|
|
'<span class="icon fa fa-cc" aria-hidden="true"></span>',
|
|
'</button>',
|
|
'<button class="control toggle-transcript" aria-disabled="false">',
|
|
'<span class="icon fa fa-quote-left" aria-hidden="true"></span>',
|
|
'</button>',
|
|
'<div class="lang menu-container" role="application">',
|
|
'<p class="sr instructions" id="lang-instructions-{courseId}"></p>',
|
|
'<button class="control language-menu" aria-disabled="false"',
|
|
'aria-describedby="lang-instructions-{courseId}" ',
|
|
'title="{langTitle}">',
|
|
'<span class="icon fa fa-caret-left" aria-hidden="true"></span>',
|
|
'</button>',
|
|
'</div>',
|
|
'</div>'
|
|
].join('')),
|
|
{
|
|
langTitle: gettext('Open language menu'),
|
|
courseId: this.state.id
|
|
}
|
|
);
|
|
|
|
var subtitlesHtml = HtmlUtils.interpolateHtml(
|
|
HtmlUtils.HTML(
|
|
[
|
|
'<div class="subtitles" role="region" id="transcript-{courseId}">',
|
|
'<h3 id="transcript-label-{courseId}" class="transcript-title sr"></h3>',
|
|
'<ol id="transcript-captions-{courseId}" class="subtitles-menu" lang="{courseLang}"></ol>',
|
|
'</div>'
|
|
].join('')),
|
|
{
|
|
courseId: this.state.id,
|
|
courseLang: this.state.lang
|
|
}
|
|
);
|
|
|
|
this.loaded = false;
|
|
this.subtitlesEl = $(HtmlUtils.ensureHtml(subtitlesHtml).toString());
|
|
this.subtitlesMenuEl = this.subtitlesEl.find('.subtitles-menu');
|
|
this.container = $(HtmlUtils.ensureHtml(langHtml).toString());
|
|
this.captionControlEl = this.container.find('.toggle-captions');
|
|
this.captionDisplayEl = this.state.el.find('.closed-captions');
|
|
this.transcriptControlEl = this.container.find('.toggle-transcript');
|
|
this.languageChooserEl = this.container.find('.lang');
|
|
this.menuChooserEl = this.languageChooserEl.parent();
|
|
|
|
if (_.keys(languages).length) {
|
|
this.renderLanguageMenu(languages);
|
|
this.fetchCaption();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Bind any necessary function callbacks to DOM events (click,
|
|
* mousemove, etc.).
|
|
*
|
|
*/
|
|
bindHandlers: function() {
|
|
var state = this.state,
|
|
events = [
|
|
'mouseover', 'mouseout', 'mousedown', 'click', 'focus', 'blur',
|
|
'keydown'
|
|
].join(' ');
|
|
|
|
this.captionControlEl.on({
|
|
click: this.toggleClosedCaptions,
|
|
keydown: this.handleCaptionToggle
|
|
});
|
|
this.transcriptControlEl.on({
|
|
click: this.toggleTranscript,
|
|
keydown: this.handleTranscriptToggle
|
|
});
|
|
this.subtitlesMenuEl.on({
|
|
mouseenter: this.onMouseEnter,
|
|
mouseleave: this.onMouseLeave,
|
|
mousemove: this.onMovement,
|
|
mousewheel: this.onMovement,
|
|
DOMMouseScroll: this.onMovement
|
|
})
|
|
.on(events, 'span[data-index]', this.onCaptionHandler);
|
|
this.container.on({
|
|
mouseenter: this.onContainerMouseEnter,
|
|
mouseleave: this.onContainerMouseLeave
|
|
});
|
|
|
|
if (this.showLanguageMenu) {
|
|
this.languageChooserEl.on({
|
|
keydown: this.handleKeypress
|
|
}, '.language-menu');
|
|
|
|
this.languageChooserEl.on({
|
|
keydown: this.handleKeypressLink
|
|
}, '.control-lang');
|
|
}
|
|
|
|
state.el
|
|
.on({
|
|
'caption:fetch': this.fetchCaption,
|
|
'caption:resize': this.onResize,
|
|
'caption:update': this.onCaptionUpdate,
|
|
ended: this.pause,
|
|
fullscreen: this.onResize,
|
|
pause: this.pause,
|
|
play: this.play,
|
|
destroy: this.destroy
|
|
});
|
|
|
|
if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
|
|
this.subtitlesMenuEl.on('scroll', state.videoControl.showControls);
|
|
}
|
|
},
|
|
|
|
onCaptionUpdate: function(event, time) {
|
|
this.updatePlayTime(time);
|
|
},
|
|
|
|
handleCaptionToggle: function(event) {
|
|
var KEY = $.ui.keyCode,
|
|
keyCode = event.keyCode;
|
|
|
|
switch (keyCode) {
|
|
case KEY.SPACE:
|
|
case KEY.ENTER:
|
|
event.preventDefault();
|
|
this.toggleClosedCaptions(event);
|
|
// no default
|
|
}
|
|
},
|
|
|
|
handleTranscriptToggle: function(event) {
|
|
var KEY = $.ui.keyCode,
|
|
keyCode = event.keyCode;
|
|
|
|
switch (keyCode) {
|
|
case KEY.SPACE:
|
|
case KEY.ENTER:
|
|
event.preventDefault();
|
|
this.toggleTranscript(event);
|
|
// no default
|
|
}
|
|
},
|
|
|
|
handleKeypressLink: function(event) {
|
|
var KEY = $.ui.keyCode,
|
|
keyCode = event.keyCode,
|
|
focused, index, total;
|
|
|
|
switch (keyCode) {
|
|
case KEY.UP:
|
|
event.preventDefault();
|
|
focused = $(':focus').parent();
|
|
index = this.languageChooserEl.find('li').index(focused);
|
|
total = this.languageChooserEl.find('li').size() - 1;
|
|
|
|
this.previousLanguageMenuItem(event, index);
|
|
break;
|
|
|
|
case KEY.DOWN:
|
|
event.preventDefault();
|
|
focused = $(':focus').parent();
|
|
index = this.languageChooserEl.find('li').index(focused);
|
|
total = this.languageChooserEl.find('li').size() - 1;
|
|
|
|
this.nextLanguageMenuItem(event, index, total);
|
|
break;
|
|
|
|
case KEY.ESCAPE:
|
|
this.closeLanguageMenu(event);
|
|
break;
|
|
|
|
case KEY.ENTER:
|
|
case KEY.SPACE:
|
|
return true;
|
|
// no default
|
|
}
|
|
return true;
|
|
},
|
|
|
|
handleKeypress: function(event) {
|
|
var KEY = $.ui.keyCode,
|
|
keyCode = event.keyCode;
|
|
|
|
switch (keyCode) {
|
|
// Handle keypresses
|
|
case KEY.ENTER:
|
|
case KEY.SPACE:
|
|
case KEY.UP:
|
|
event.preventDefault();
|
|
this.openLanguageMenu(event);
|
|
break;
|
|
|
|
case KEY.ESCAPE:
|
|
this.closeLanguageMenu(event);
|
|
break;
|
|
// no default
|
|
}
|
|
|
|
return event.keyCode === KEY.TAB;
|
|
},
|
|
|
|
nextLanguageMenuItem: function(event, index, total) {
|
|
event.preventDefault();
|
|
|
|
if (event.altKey || event.shiftKey) {
|
|
return true;
|
|
}
|
|
|
|
if (index === total) {
|
|
this.languageChooserEl
|
|
.find('.control-lang').first()
|
|
.focus();
|
|
} else {
|
|
this.languageChooserEl
|
|
.find('li:eq(' + index + ')')
|
|
.next()
|
|
.find('.control-lang')
|
|
.focus();
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
previousLanguageMenuItem: function(event, index) {
|
|
event.preventDefault();
|
|
|
|
if (event.altKey || event.shiftKey) {
|
|
return true;
|
|
}
|
|
|
|
if (index === 0) {
|
|
this.languageChooserEl
|
|
.find('.control-lang').last()
|
|
.focus();
|
|
} else {
|
|
this.languageChooserEl
|
|
.find('li:eq(' + index + ')')
|
|
.prev()
|
|
.find('.control-lang')
|
|
.focus();
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
openLanguageMenu: function(event) {
|
|
var button = this.languageChooserEl,
|
|
menu = button.parent().find('.menu');
|
|
|
|
event.preventDefault();
|
|
|
|
button
|
|
.addClass('is-opened');
|
|
|
|
menu
|
|
.find('.control-lang').last()
|
|
.focus();
|
|
},
|
|
|
|
closeLanguageMenu: function(event) {
|
|
var button = this.languageChooserEl;
|
|
event.preventDefault();
|
|
|
|
button
|
|
.removeClass('is-opened')
|
|
.find('.language-menu')
|
|
.focus();
|
|
},
|
|
|
|
onCaptionHandler: function(event) {
|
|
switch (event.type) {
|
|
case 'mouseover':
|
|
case 'mouseout':
|
|
this.captionMouseOverOut(event);
|
|
break;
|
|
case 'mousedown':
|
|
this.captionMouseDown(event);
|
|
break;
|
|
case 'click':
|
|
this.captionClick(event);
|
|
break;
|
|
case 'focusin':
|
|
this.captionFocus(event);
|
|
break;
|
|
case 'focusout':
|
|
this.captionBlur(event);
|
|
break;
|
|
case 'keydown':
|
|
this.captionKeyDown(event);
|
|
break;
|
|
// no default
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Opens language menu.
|
|
*
|
|
* @param {jquery Event} event
|
|
*/
|
|
onContainerMouseEnter: function(event) {
|
|
event.preventDefault();
|
|
$(event.currentTarget).find('.lang').addClass('is-opened');
|
|
|
|
// We only want to fire the analytics event if a menu is
|
|
// present instead of on the container hover, since it wraps
|
|
// the "CC" and "Transcript" buttons as well.
|
|
if ($(event.currentTarget).find('.lang').length) {
|
|
this.state.el.trigger('language_menu:show');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Closes language menu.
|
|
*
|
|
* @param {jquery Event} event
|
|
*/
|
|
onContainerMouseLeave: function(event) {
|
|
event.preventDefault();
|
|
$(event.currentTarget).find('.lang').removeClass('is-opened');
|
|
|
|
// We only want to fire the analytics event if a menu is
|
|
// present instead of on the container hover, since it wraps
|
|
// the "CC" and "Transcript" buttons as well.
|
|
if ($(event.currentTarget).find('.lang').length) {
|
|
this.state.el.trigger('language_menu:hide');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Freezes moving of captions when mouse is over them.
|
|
*
|
|
* @param {jquery Event} event
|
|
*/
|
|
onMouseEnter: function() {
|
|
if (this.frozen) {
|
|
clearTimeout(this.frozen);
|
|
}
|
|
|
|
this.frozen = setTimeout(
|
|
this.onMouseLeave,
|
|
this.state.config.captionsFreezeTime
|
|
);
|
|
},
|
|
|
|
/**
|
|
* @desc Unfreezes moving of captions when mouse go out.
|
|
*
|
|
* @param {jquery Event} event
|
|
*/
|
|
onMouseLeave: function() {
|
|
if (this.frozen) {
|
|
clearTimeout(this.frozen);
|
|
}
|
|
|
|
this.frozen = null;
|
|
|
|
if (this.playing) {
|
|
this.scrollCaption();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Freezes moving of captions when mouse is moving over them.
|
|
*
|
|
* @param {jquery Event} event
|
|
*/
|
|
onMovement: function() {
|
|
this.onMouseEnter();
|
|
},
|
|
|
|
/**
|
|
* @desc Gets the correct start and end times from the state configuration
|
|
*
|
|
* @returns {array} if [startTime, endTime] are defined
|
|
*/
|
|
getStartEndTimes: function() {
|
|
// due to the way config.startTime/endTime are
|
|
// processed in 03_video_player.js, we assume
|
|
// endTime can be an integer or null,
|
|
// and startTime is an integer > 0
|
|
var config = this.state.config;
|
|
var startTime = config.startTime * 1000;
|
|
var endTime = (config.endTime !== null) ? config.endTime * 1000 : null;
|
|
return [startTime, endTime];
|
|
},
|
|
|
|
/**
|
|
* @desc Gets captions within the start / end times stored within this.state.config
|
|
*
|
|
* @returns {object} {start, captions} parallel arrays of
|
|
* start times and corresponding captions
|
|
*/
|
|
getBoundedCaptions: function() {
|
|
// get start and caption. If startTime and endTime
|
|
// are specified, filter by that range.
|
|
var times = this.getStartEndTimes();
|
|
// eslint-disable-next-line prefer-spread
|
|
var results = this.sjson.filter.apply(this.sjson, times);
|
|
var start = results.start;
|
|
var captions = results.captions;
|
|
|
|
return {
|
|
start: start,
|
|
captions: captions
|
|
};
|
|
},
|
|
|
|
/**
|
|
* @desc Fetch the caption file specified by the user. Upon successful
|
|
* receipt of the file, the captions will be rendered.
|
|
* @param {boolean} [fetchWithYoutubeId] Fetch youtube captions if true.
|
|
* @returns {boolean}
|
|
* true: The user specified a caption file. NOTE: if an error happens
|
|
* while the specified file is being retrieved (for example the
|
|
* file is missing on the server), this function will still return
|
|
* true.
|
|
* false: No caption file was specified, or an empty string was
|
|
* specified for the Youtube type player.
|
|
*/
|
|
fetchCaption: function(fetchWithYoutubeId) {
|
|
var self = this,
|
|
state = this.state,
|
|
language = state.getCurrentLanguage(),
|
|
url = state.config.transcriptTranslationUrl.replace('__lang__', language),
|
|
data, youtubeId;
|
|
|
|
if (this.loaded) {
|
|
this.hideCaptions(false);
|
|
}
|
|
|
|
if (this.fetchXHR && this.fetchXHR.abort) {
|
|
this.fetchXHR.abort();
|
|
}
|
|
|
|
if (state.videoType === 'youtube' || fetchWithYoutubeId) {
|
|
try {
|
|
youtubeId = state.youtubeId('1.0');
|
|
} catch (err) {
|
|
youtubeId = null;
|
|
}
|
|
|
|
if (!youtubeId) {
|
|
return false;
|
|
}
|
|
|
|
data = {videoId: youtubeId};
|
|
}
|
|
|
|
state.el.removeClass('is-captions-rendered');
|
|
// Fetch the captions file. If no file was specified, or if an error
|
|
// occurred, then we hide the captions panel, and the "Transcript" button
|
|
this.fetchXHR = $.ajaxWithPrefix({
|
|
url: url,
|
|
notifyOnError: false,
|
|
data: data,
|
|
success: function(sjson) {
|
|
var results, start, captions;
|
|
self.sjson = new Sjson(sjson);
|
|
results = self.getBoundedCaptions();
|
|
start = results.start;
|
|
captions = results.captions;
|
|
|
|
if (self.loaded) {
|
|
if (self.rendered) {
|
|
self.renderCaption(start, captions);
|
|
self.updatePlayTime(state.videoPlayer.currentTime);
|
|
}
|
|
} else {
|
|
if (state.isTouch) {
|
|
HtmlUtils.setHtml(
|
|
self.subtitlesEl.find('.subtitles-menu'),
|
|
HtmlUtils.joinHtml(
|
|
HtmlUtils.HTML('<li>'),
|
|
gettext('Transcript will be displayed when you start playing the video.'),
|
|
HtmlUtils.HTML('</li>')
|
|
)
|
|
);
|
|
} else {
|
|
self.renderCaption(start, captions);
|
|
}
|
|
self.hideCaptions(self.hideCaptionsOnLoad);
|
|
HtmlUtils.append(
|
|
self.state.el.find('.video-wrapper').parent(),
|
|
HtmlUtils.HTML(self.subtitlesEl)
|
|
);
|
|
HtmlUtils.append(
|
|
self.state.el.find('.secondary-controls'),
|
|
HtmlUtils.HTML(self.container)
|
|
);
|
|
self.bindHandlers();
|
|
}
|
|
|
|
self.loaded = true;
|
|
},
|
|
error: function(jqXHR, textStatus, errorThrown) {
|
|
var canFetchWithYoutubeId;
|
|
console.log('[Video info]: ERROR while fetching captions.');
|
|
console.log(
|
|
'[Video info]: STATUS:', textStatus
|
|
+ ', MESSAGE:', '' + errorThrown
|
|
);
|
|
// If initial list of languages has more than 1 item, check
|
|
// for availability other transcripts.
|
|
// If player mode is html5 and there are no initial languages
|
|
// then try to fetch youtube version of transcript with
|
|
// youtubeId.
|
|
if (_.keys(state.config.transcriptLanguages).length > 1) {
|
|
self.fetchAvailableTranslations();
|
|
} else if (!fetchWithYoutubeId && state.videoType === 'html5') {
|
|
canFetchWithYoutubeId = self.fetchCaption(true);
|
|
if (canFetchWithYoutubeId) {
|
|
console.log('[Video info]: Html5 mode fetching caption with youtubeId.'); // eslint-disable-line max-len, no-console
|
|
} else {
|
|
self.hideCaptions(true);
|
|
self.languageChooserEl.hide();
|
|
self.hideClosedCaptions();
|
|
}
|
|
} else {
|
|
self.hideCaptions(true);
|
|
self.languageChooserEl.hide();
|
|
self.hideClosedCaptions();
|
|
}
|
|
}
|
|
});
|
|
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* @desc Fetch the list of available language codes. Upon successful receipt
|
|
* the list of available languages will be updated.
|
|
*
|
|
* @returns {jquery Promise}
|
|
*/
|
|
fetchAvailableTranslations: function() {
|
|
var self = this,
|
|
state = this.state;
|
|
|
|
this.availableTranslationsXHR = $.ajaxWithPrefix({
|
|
url: state.config.transcriptAvailableTranslationsUrl,
|
|
notifyOnError: false,
|
|
success: function(response) {
|
|
var currentLanguages = state.config.transcriptLanguages,
|
|
newLanguages = _.pick(currentLanguages, response);
|
|
|
|
// Update property with available currently translations.
|
|
state.config.transcriptLanguages = newLanguages;
|
|
// Remove an old language menu.
|
|
self.container.find('.langs-list').remove();
|
|
|
|
if (_.keys(newLanguages).length) {
|
|
self.renderLanguageMenu(newLanguages);
|
|
}
|
|
},
|
|
error: function() {
|
|
self.hideCaptions(true);
|
|
self.languageChooserEl.hide();
|
|
}
|
|
});
|
|
|
|
return this.availableTranslationsXHR;
|
|
},
|
|
|
|
/**
|
|
* @desc Recalculates and updates the height of the container of captions.
|
|
*
|
|
*/
|
|
onResize: function() {
|
|
this.subtitlesEl
|
|
.find('.spacing').first()
|
|
.height(this.topSpacingHeight());
|
|
|
|
this.subtitlesEl
|
|
.find('.spacing').last()
|
|
.height(this.bottomSpacingHeight());
|
|
|
|
this.scrollCaption();
|
|
this.setSubtitlesHeight();
|
|
},
|
|
|
|
/**
|
|
* @desc Create any necessary DOM elements, attach them, and set their
|
|
* initial configuration for the Language menu.
|
|
*
|
|
* @param {object} languages Dictionary where key is language code,
|
|
* value - language label
|
|
*
|
|
*/
|
|
renderLanguageMenu: function(languages) {
|
|
var self = this,
|
|
state = this.state,
|
|
$menu = $('<ol class="langs-list menu">'),
|
|
currentLang = state.getCurrentLanguage(),
|
|
$li, $link, linkHtml;
|
|
|
|
if (_.keys(languages).length < 2) {
|
|
// Remove the menu toggle button
|
|
self.container.find('.lang').remove();
|
|
return;
|
|
}
|
|
|
|
this.showLanguageMenu = true;
|
|
|
|
$.each(languages, function(code, label) {
|
|
$li = $('<li />', {'data-lang-code': code});
|
|
linkHtml = HtmlUtils.joinHtml(
|
|
HtmlUtils.HTML('<button class="control control-lang">'),
|
|
label,
|
|
HtmlUtils.HTML('</button>')
|
|
);
|
|
$link = $(linkHtml.toString());
|
|
|
|
if (currentLang === code) {
|
|
$li.addClass('is-active');
|
|
$link.attr('aria-pressed', 'true');
|
|
}
|
|
|
|
$li.append($link);
|
|
$menu.append($li);
|
|
});
|
|
|
|
HtmlUtils.append(
|
|
this.languageChooserEl,
|
|
HtmlUtils.HTML($menu)
|
|
);
|
|
|
|
$menu.on('click', '.control-lang', function(e) {
|
|
var el = $(e.currentTarget).parent(),
|
|
captionState = self.state,
|
|
langCode = el.data('lang-code');
|
|
|
|
if (captionState.lang !== langCode) {
|
|
captionState.lang = langCode;
|
|
el.addClass('is-active')
|
|
.siblings('li')
|
|
.removeClass('is-active')
|
|
.find('.control-lang')
|
|
.attr('aria-pressed', 'false');
|
|
|
|
$(e.currentTarget).attr('aria-pressed', 'true');
|
|
|
|
captionState.el.trigger('language_menu:change', [langCode]);
|
|
self.fetchCaption();
|
|
|
|
// update the closed-captions lang attribute
|
|
self.captionDisplayEl.attr('lang', langCode);
|
|
|
|
// update the transcript lang attribute
|
|
self.subtitlesMenuEl.attr('lang', langCode);
|
|
self.closeLanguageMenu(e);
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @desc Create any necessary DOM elements, attach them, and set their
|
|
* initial configuration.
|
|
*
|
|
* @param {jQuery element} container Element in which captions will be
|
|
* inserted.
|
|
* @param {array} start List of start times for the video.
|
|
* @param {array} captions List of captions for the video.
|
|
* @returns {object} jQuery's Promise object
|
|
*
|
|
*/
|
|
buildCaptions: function(container, start, captions) {
|
|
var process = function(text, index) {
|
|
var $spanEl = $('<span>', {
|
|
role: 'link',
|
|
'data-index': index,
|
|
'data-start': start[index],
|
|
tabindex: 0
|
|
});
|
|
|
|
HtmlUtils.setHtml($($spanEl), HtmlUtils.HTML(text.toString()));
|
|
|
|
return $spanEl.wrap('<li>').parent()[0]; // xss-lint: disable=javascript-jquery-insertion
|
|
};
|
|
|
|
return AsyncProcess.array(captions, process).done(function(list) {
|
|
HtmlUtils.append(
|
|
container,
|
|
HtmlUtils.HTML(list)
|
|
);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* @desc Initiates creating of captions and set their initial configuration.
|
|
*
|
|
* @param {array} start List of start times for the video.
|
|
* @param {array} captions List of captions for the video.
|
|
*
|
|
*/
|
|
renderCaption: function(start, captions) {
|
|
var self = this;
|
|
|
|
var onRender = function() {
|
|
self.addPaddings();
|
|
// Enables or disables automatic scrolling of the captions when the
|
|
// video is playing. This feature has to be disabled when tabbing
|
|
// through them as it interferes with that action. Initially, have
|
|
// this flag enabled as we assume mouse use. Then, if the first
|
|
// caption (through forward tabbing) or the last caption (through
|
|
// backwards tabbing) gets the focus, disable that feature.
|
|
// Re-enable it if tabbing then cycles out of the the captions.
|
|
self.autoScrolling = true;
|
|
// Keeps track of where the focus is situated in the array of
|
|
// captions. Used to implement the automatic scrolling behavior and
|
|
// decide if the outline around a caption has to be hidden or shown
|
|
// on a mouseenter or mouseleave. Initially, no caption has the
|
|
// focus, set the index to -1.
|
|
self.currentCaptionIndex = -1;
|
|
// Used to track if the focus is coming from a click or tabbing. This
|
|
// has to be known to decide if, when a caption gets the focus, an
|
|
// outline has to be drawn (tabbing) or not (mouse click).
|
|
self.isMouseFocus = false;
|
|
self.rendered = true;
|
|
self.state.el.addClass('is-captions-rendered');
|
|
|
|
self.subtitlesEl
|
|
.attr('aria-label', gettext('Activating a link in this group will skip to the corresponding point in the video.')); // eslint-disable-line max-len
|
|
|
|
self.subtitlesEl.find('.transcript-title')
|
|
.text(gettext('Video transcript'));
|
|
|
|
self.subtitlesEl.find('.transcript-start')
|
|
.text(gettext('Start of transcript. Skip to the end.'))
|
|
.attr('lang', $('html').attr('lang'));
|
|
|
|
self.subtitlesEl.find('.transcript-end')
|
|
.text(gettext('End of transcript. Skip to the start.'))
|
|
.attr('lang', $('html').attr('lang'));
|
|
|
|
self.container.find('.menu-container .instructions')
|
|
.text(gettext('Press the UP arrow key to enter the language menu then use UP and DOWN arrow keys to navigate language options. Press ENTER to change to the selected language.')); // eslint-disable-line max-len
|
|
};
|
|
|
|
this.rendered = false;
|
|
this.subtitlesMenuEl.empty();
|
|
this.setSubtitlesHeight();
|
|
this.buildCaptions(this.subtitlesMenuEl, start, captions).done(onRender);
|
|
},
|
|
|
|
/**
|
|
* @desc Sets top and bottom spacing height and make sure they are taken
|
|
* out of the tabbing order.
|
|
*
|
|
*/
|
|
addPaddings: function() {
|
|
var topSpacer = HtmlUtils.interpolateHtml(
|
|
HtmlUtils.HTML([
|
|
'<li class="spacing" style="height: {height}px">',
|
|
'<a href="#transcript-end-{id}" id="transcript-start-{id}" class="transcript-start"></a>', // eslint-disable-line max-len, indent
|
|
'</li>'
|
|
].join('')),
|
|
{
|
|
id: this.state.id,
|
|
height: this.topSpacingHeight()
|
|
}
|
|
);
|
|
|
|
var bottomSpacer = HtmlUtils.interpolateHtml(
|
|
HtmlUtils.HTML([
|
|
'<li class="spacing" style="height: {height}px">',
|
|
'<a href="#transcript-start-{id}" id="transcript-end-{id}" class="transcript-end"></a>', // eslint-disable-line max-len, indent
|
|
'</li>'
|
|
].join('')),
|
|
{
|
|
id: this.state.id,
|
|
height: this.bottomSpacingHeight()
|
|
}
|
|
);
|
|
|
|
HtmlUtils.prepend(
|
|
this.subtitlesMenuEl,
|
|
topSpacer
|
|
);
|
|
|
|
HtmlUtils.append(
|
|
this.subtitlesMenuEl,
|
|
bottomSpacer
|
|
);
|
|
},
|
|
|
|
/**
|
|
* @desc
|
|
* On mouseOver: Hides the outline of a caption that has been tabbed to.
|
|
* On mouseOut: Shows the outline of a caption that has been tabbed to.
|
|
*
|
|
* @param {jquery Event} event
|
|
*
|
|
*/
|
|
captionMouseOverOut: function(event) {
|
|
var $caption = $(event.target),
|
|
captionIndex = parseInt($caption.attr('data-index'), 10);
|
|
|
|
if (captionIndex === this.currentCaptionIndex) {
|
|
if (event.type === 'mouseover') {
|
|
$caption.removeClass('focused');
|
|
} else { // mouseout
|
|
$caption.addClass('focused');
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Handles mousedown event on concrete caption.
|
|
*
|
|
* @param {jquery Event} event
|
|
*
|
|
*/
|
|
captionMouseDown: function(event) {
|
|
var $caption = $(event.target);
|
|
|
|
this.isMouseFocus = true;
|
|
this.autoScrolling = true;
|
|
$caption.removeClass('focused');
|
|
this.currentCaptionIndex = -1;
|
|
},
|
|
|
|
/**
|
|
* @desc Handles click event on concrete caption.
|
|
*
|
|
* @param {jquery Event} event
|
|
*
|
|
*/
|
|
captionClick: function(event) {
|
|
this.seekPlayer(event);
|
|
},
|
|
|
|
/**
|
|
* @desc Handles focus event on concrete caption.
|
|
*
|
|
* @param {jquery Event} event
|
|
*
|
|
*/
|
|
captionFocus: function(event) {
|
|
var $caption = $(event.target),
|
|
container = $caption.parent(),
|
|
captionIndex = parseInt($caption.attr('data-index'), 10);
|
|
// If the focus comes from a mouse click, hide the outline, turn on
|
|
// automatic scrolling and set currentCaptionIndex to point outside of
|
|
// caption list (ie -1) to disable mouseenter, mouseleave behavior.
|
|
if (this.isMouseFocus) {
|
|
this.autoScrolling = true;
|
|
container.removeClass('focused');
|
|
this.currentCaptionIndex = -1;
|
|
} else {
|
|
// If the focus comes from tabbing, show the outline and turn off
|
|
// automatic scrolling.
|
|
|
|
this.currentCaptionIndex = captionIndex;
|
|
container.addClass('focused');
|
|
// The second and second to last elements turn automatic scrolling
|
|
// off again as it may have been enabled in captionBlur.
|
|
if (
|
|
captionIndex <= 1
|
|
|| captionIndex >= this.sjson.getSize() - 2
|
|
) {
|
|
this.autoScrolling = false;
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Handles blur event on concrete caption.
|
|
*
|
|
* @param {jquery Event} event
|
|
*
|
|
*/
|
|
captionBlur: function(event) {
|
|
var $caption = $(event.target),
|
|
container = $caption.parent(),
|
|
captionIndex = parseInt($caption.attr('data-index'), 10);
|
|
|
|
container.removeClass('focused');
|
|
// If we are on first or last index, we have to turn automatic scroll
|
|
// on again when losing focus. There is no way to know in what
|
|
// direction we are tabbing. So we could be on the first element and
|
|
// tabbing back out of the captions or on the last element and tabbing
|
|
// forward out of the captions.
|
|
if (captionIndex === 0
|
|
|| captionIndex === this.sjson.getSize() - 1) {
|
|
this.autoScrolling = true;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Handles keydown event on concrete caption.
|
|
*
|
|
* @param {jquery Event} event
|
|
*
|
|
*/
|
|
captionKeyDown: function(event) {
|
|
this.isMouseFocus = false;
|
|
if (event.which === 13) { // Enter key
|
|
this.seekPlayer(event);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Scrolls caption container to make active caption visible.
|
|
*
|
|
*/
|
|
scrollCaption: function() {
|
|
var el = this.subtitlesEl.find('.current:first');
|
|
|
|
// Automatic scrolling gets disabled if one of the captions has
|
|
// received focus through tabbing.
|
|
if (
|
|
!this.frozen
|
|
&& el.length
|
|
&& this.autoScrolling
|
|
) {
|
|
this.subtitlesEl.scrollTo(
|
|
el,
|
|
{
|
|
offset: -1 * this.calculateOffset(el)
|
|
}
|
|
);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Updates flags on play
|
|
*
|
|
*/
|
|
play: function() {
|
|
var captions, startAndCaptions, start;
|
|
if (this.loaded) {
|
|
if (!this.rendered) {
|
|
startAndCaptions = this.getBoundedCaptions();
|
|
start = startAndCaptions.start;
|
|
captions = startAndCaptions.captions;
|
|
this.renderCaption(start, captions);
|
|
}
|
|
|
|
this.playing = true;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Updates flags on pause
|
|
*
|
|
*/
|
|
pause: function() {
|
|
if (this.loaded) {
|
|
this.playing = false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Updates captions UI on paying.
|
|
*
|
|
* @param {number} time Time in seconds.
|
|
*
|
|
*/
|
|
updatePlayTime: function(time) {
|
|
var state = this.state,
|
|
params, newIndex, times;
|
|
|
|
if (this.loaded) {
|
|
if (state.isFlashMode()) {
|
|
time = Time.convert(time, state.speed, '1.0');
|
|
}
|
|
|
|
time = Math.round(time * 1000 + 100);
|
|
times = this.getStartEndTimes();
|
|
// if start and end times are defined, limit search.
|
|
// else, use the entire list of video captions
|
|
params = [time].concat(times);
|
|
// eslint-disable-next-line prefer-spread
|
|
newIndex = this.sjson.search.apply(this.sjson, params);
|
|
|
|
if (
|
|
typeof newIndex !== 'undefined'
|
|
&& newIndex !== -1
|
|
&& this.currentIndex !== newIndex
|
|
) {
|
|
if (typeof this.currentIndex !== 'undefined') {
|
|
this.subtitlesEl
|
|
.find('li.current')
|
|
.removeClass('current');
|
|
}
|
|
|
|
this.subtitlesEl
|
|
.find("span[data-index='" + newIndex + "']")
|
|
.parent()
|
|
.addClass('current');
|
|
|
|
this.currentIndex = newIndex;
|
|
this.captionDisplayEl.text(this.subtitlesEl.find("span[data-index='" + newIndex + "']").text());
|
|
this.scrollCaption();
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Sends log to the server on caption seek.
|
|
*
|
|
* @param {jquery Event} event
|
|
*
|
|
*/
|
|
seekPlayer: function(event) {
|
|
var state = this.state,
|
|
time = parseInt($(event.target).data('start'), 10);
|
|
|
|
if (state.isFlashMode()) {
|
|
time = Math.round(Time.convert(time, '1.0', state.speed));
|
|
}
|
|
|
|
state.trigger(
|
|
'videoPlayer.onCaptionSeek',
|
|
{
|
|
type: 'onCaptionSeek',
|
|
time: time / 1000
|
|
}
|
|
);
|
|
|
|
event.preventDefault();
|
|
},
|
|
|
|
/**
|
|
* @desc Calculates offset for paddings.
|
|
*
|
|
* @param {jquery element} element Top or bottom padding element.
|
|
* @returns {number} Offset for the passed padding element.
|
|
*
|
|
*/
|
|
calculateOffset: function(element) {
|
|
return this.captionHeight() / 2 - element.height() / 2;
|
|
},
|
|
|
|
/**
|
|
* @desc Calculates offset for the top padding element.
|
|
*
|
|
* @returns {number} Offset for the passed top padding element.
|
|
*
|
|
*/
|
|
topSpacingHeight: function() {
|
|
return this.calculateOffset(
|
|
this.subtitlesEl.find('li:not(.spacing)').first()
|
|
);
|
|
},
|
|
|
|
/**
|
|
* @desc Calculates offset for the bottom padding element.
|
|
*
|
|
* @returns {number} Offset for the passed bottom padding element.
|
|
*
|
|
*/
|
|
bottomSpacingHeight: function() {
|
|
return this.calculateOffset(
|
|
this.subtitlesEl.find('li:not(.spacing)').last()
|
|
);
|
|
},
|
|
|
|
handleCaptioningCookie: function() {
|
|
if ($.cookie('show_closed_captions') === 'true') {
|
|
this.state.showClosedCaptions = true;
|
|
this.showClosedCaptions();
|
|
|
|
// keep it going until turned off
|
|
$.cookie('show_closed_captions', 'true', {
|
|
expires: 3650,
|
|
path: '/'
|
|
});
|
|
} else {
|
|
this.hideClosedCaptions();
|
|
}
|
|
},
|
|
|
|
toggleClosedCaptions: function(event) {
|
|
event.preventDefault();
|
|
|
|
if (this.state.el.hasClass('has-captions')) {
|
|
this.state.showClosedCaptions = false;
|
|
this.updateCaptioningCookie(false);
|
|
this.hideClosedCaptions();
|
|
} else {
|
|
this.state.showClosedCaptions = true;
|
|
this.updateCaptioningCookie(true);
|
|
this.showClosedCaptions();
|
|
}
|
|
},
|
|
|
|
showClosedCaptions: function() {
|
|
var text = gettext('Hide closed captions');
|
|
this.state.el.addClass('has-captions');
|
|
|
|
this.captionDisplayEl
|
|
.show()
|
|
.addClass('is-visible')
|
|
.attr('lang', this.state.lang);
|
|
|
|
this.captionControlEl
|
|
.addClass('is-active')
|
|
.attr('title', text)
|
|
.attr('aria-label', text);
|
|
|
|
if (this.subtitlesEl.find('.current').text()) {
|
|
this.captionDisplayEl
|
|
.text(this.subtitlesEl.find('.current').text());
|
|
} else {
|
|
this.captionDisplayEl
|
|
.text(gettext('(Caption will be displayed when you start playing the video.)'));
|
|
}
|
|
|
|
this.state.el.trigger('captions:show');
|
|
},
|
|
|
|
hideClosedCaptions: function() {
|
|
var text = gettext('Turn on closed captioning');
|
|
this.state.el.removeClass('has-captions');
|
|
|
|
this.captionDisplayEl
|
|
.hide()
|
|
.removeClass('is-visible');
|
|
|
|
this.captionControlEl
|
|
.removeClass('is-active')
|
|
.attr('title', text)
|
|
.attr('aria-label', text);
|
|
|
|
this.state.el.trigger('captions:hide');
|
|
},
|
|
|
|
updateCaptioningCookie: function(method) {
|
|
if (method) {
|
|
$.cookie('show_closed_captions', 'true', {
|
|
expires: 3650,
|
|
path: '/'
|
|
});
|
|
} else {
|
|
$.cookie('show_closed_captions', null, {
|
|
path: '/'
|
|
});
|
|
}
|
|
},
|
|
|
|
/**
|
|
* This runs when the video block is first rendered and sets the initial visibility
|
|
* of the transcript panel based on the value of the 'show_transcript' cookie and/or
|
|
* the block's showCaptions setting.
|
|
*/
|
|
setTranscriptVisibility: function() {
|
|
var hideCaptionsOnRender = !this.state.config.showCaptions;
|
|
|
|
if ($.cookie('show_transcript') === 'true') {
|
|
this.hideCaptionsOnLoad = false;
|
|
// Keep it going until turned off.
|
|
this.updateTranscriptCookie(true);
|
|
} else if ($.cookie('show_transcript') === 'false') {
|
|
hideCaptionsOnRender = true;
|
|
this.hideCaptionsOnLoad = true;
|
|
} else {
|
|
this.hideCaptionsOnLoad = !this.state.config.showCaptions;
|
|
}
|
|
|
|
if (hideCaptionsOnRender) {
|
|
this.state.el.addClass('closed');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Shows/Hides transcript on click `transcript` button
|
|
*
|
|
* @param {jquery Event} event
|
|
*
|
|
*/
|
|
toggleTranscript: function(event) {
|
|
event.preventDefault();
|
|
if (this.state.el.hasClass('closed')) {
|
|
this.hideCaptions(false, true);
|
|
this.updateTranscriptCookie(true);
|
|
} else {
|
|
this.hideCaptions(true, true);
|
|
this.updateTranscriptCookie(false);
|
|
}
|
|
},
|
|
|
|
updateTranscriptCookie: function(showTranscript) {
|
|
if (showTranscript) {
|
|
$.cookie('show_transcript', 'true', {
|
|
expires: 3650,
|
|
path: '/'
|
|
});
|
|
} else {
|
|
$.cookie('show_transcript', 'false', {
|
|
path: '/'
|
|
});
|
|
}
|
|
},
|
|
|
|
listenForDragDrop: function() {
|
|
var captions = this.captionDisplayEl['0'];
|
|
|
|
if (typeof Draggabilly === 'function') {
|
|
// eslint-disable-next-line no-new
|
|
new Draggabilly(captions, {containment: true});
|
|
} else {
|
|
console.log('Closed captioning available but not draggable');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Shows/Hides the transcript panel.
|
|
*
|
|
* @param {boolean} hideCaptions if `true` hides the transcript panel,
|
|
* otherwise - show.
|
|
*/
|
|
hideCaptions: function(hideCaptions, triggerEvent) {
|
|
var transcriptControlEl = this.transcriptControlEl,
|
|
state = this.state,
|
|
text;
|
|
|
|
if (hideCaptions) {
|
|
state.captionsHidden = true;
|
|
state.el.addClass('closed');
|
|
text = gettext('Turn on transcripts');
|
|
if (triggerEvent) {
|
|
this.state.el.trigger('transcript:hide');
|
|
}
|
|
|
|
transcriptControlEl
|
|
.removeClass('is-active')
|
|
.attr('title', gettext(text))
|
|
.attr('aria-label', text);
|
|
} else {
|
|
state.captionsHidden = false;
|
|
state.el.removeClass('closed');
|
|
this.scrollCaption();
|
|
text = gettext('Turn off transcripts');
|
|
if (triggerEvent) {
|
|
this.state.el.trigger('transcript:show');
|
|
}
|
|
|
|
transcriptControlEl
|
|
.addClass('is-active')
|
|
.attr('title', gettext(text))
|
|
.attr('aria-label', text);
|
|
}
|
|
|
|
if (state.resizer) {
|
|
if (state.isFullScreen) {
|
|
state.resizer.setMode('both');
|
|
} else {
|
|
state.resizer.alignByWidthOnly();
|
|
}
|
|
}
|
|
|
|
this.setSubtitlesHeight();
|
|
},
|
|
|
|
/**
|
|
* @desc Return the caption container height.
|
|
*
|
|
* @returns {number} event Height of the container in pixels.
|
|
*
|
|
*/
|
|
captionHeight: function() {
|
|
var state = this.state;
|
|
if (state.isFullScreen) {
|
|
return state.container.height() - state.videoFullScreen.height;
|
|
} else {
|
|
return state.container.height();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @desc Sets the height of the caption container element.
|
|
*
|
|
*/
|
|
setSubtitlesHeight: function() {
|
|
var height = 0,
|
|
state = this.state;
|
|
// on page load captionHidden = undefined
|
|
if ((state.captionsHidden === undefined && this.hideCaptionsOnLoad)
|
|
|| state.captionsHidden === true
|
|
) {
|
|
// In case of html5 autoshowing subtitles, we adjust height of
|
|
// subs, by height of scrollbar.
|
|
height = state.el.find('.video-controls').height()
|
|
+ 0.5 * state.el.find('.slider').height();
|
|
// Height of videoControl does not contain height of slider.
|
|
// css is set to absolute, to avoid yanking when slider
|
|
// autochanges its height.
|
|
}
|
|
|
|
this.subtitlesEl.css({
|
|
maxHeight: this.captionHeight() - height
|
|
});
|
|
}
|
|
};
|
|
|
|
return VideoCaption;
|
|
});
|
|
}(RequireJS.define));
|