'
+ ].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 Shows/Hides Google disclaimer based on captions being AI generated and
+ * if ClosedCaptions are being shown.
+ *
+ * @param {array} captions List of captions for the video.
+ *
+ * @returns {boolean}
+ */
+ toggleGoogleDisclaimer: function (captions) {
+ var self = this,
+ state = this.state,
+ aIGeneratedSpan = '',
+ captionsAIGenerated = captions.some(caption => caption.includes(aIGeneratedSpan));
+
+ if (!self.hideCaptionsOnLoad && !state.captionsHidden) {
+ if (captionsAIGenerated) {
+ state.el.find('.google-disclaimer').show();
+ self.shouldShowGoogleDisclaimer = true;
+ } else {
+ state.el.find('.google-disclaimer').hide();
+ self.shouldShowGoogleDisclaimer = false;
+ }
+ }
+ },
+
+ /**
+ * @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;
+
+ self.toggleGoogleDisclaimer(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('
'),
+ gettext('Transcript will be displayed when you start playing the video.'),
+ HtmlUtils.HTML('
')
+ )
+ );
+ } 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 = $(''),
+ 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 = $('', {'data-lang-code': code});
+ linkHtml = HtmlUtils.joinHtml(
+ HtmlUtils.HTML('')
+ );
+ $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 = $('', {
+ role: 'link',
+ 'data-index': index,
+ 'data-start': start[index],
+ tabindex: 0
+ });
+
+ HtmlUtils.setHtml($($spanEl), HtmlUtils.HTML(text.toString()));
+
+ return $spanEl.wrap('
').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([
+ '
'
+ ].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 = 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')
+ .attr('aria-current', 'false')
+ .removeClass('current');
+ }
+ this.subtitlesEl
+ .find("span[data-index='" + newIndex + "']")
+ .parent()
+ .attr('aria-current', 'true')
+ .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(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,
+ self = this,
+ 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');
+ }
+
+ state.el.find('.google-disclaimer').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');
+ }
+
+ if (self.shouldShowGoogleDisclaimer) {
+ state.el.find('.google-disclaimer').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
+ });
+ }
+};
+
+
+// Export as default
+export default VideoCaption;
diff --git a/xmodule/assets/video/public/js/video_context_menu.js b/xmodule/assets/video/public/js/video_context_menu.js
new file mode 100644
index 000000000000..6f9906d8309b
--- /dev/null
+++ b/xmodule/assets/video/public/js/video_context_menu.js
@@ -0,0 +1,699 @@
+import $ from 'jquery';
+import _ from 'underscore';
+import Component from './00_component.js';
+
+const AbstractItem = Component.extend({
+ initialize: function (options) {
+ this.options = $.extend(true, {
+ label: '',
+ prefix: 'edx-',
+ dataAttrs: {menu: this},
+ attrs: {},
+ items: [],
+ callback: $.noop,
+ initialize: $.noop
+ }, options);
+
+ this.id = _.uniqueId();
+ this.element = this.createElement();
+ this.element.attr(this.options.attrs).data(this.options.dataAttrs);
+ this.children = [];
+ this.delegateEvents();
+ this.options.initialize.call(this, this);
+ },
+ destroy: function () {
+ _.invoke(this.getChildren(), 'destroy');
+ this.undelegateEvents();
+ this.getElement().remove();
+ },
+ open: function () {
+ this.getElement().addClass('is-opened');
+ return this;
+ },
+ close: function () {
+ },
+ closeSiblings: function () {
+ _.invoke(this.getSiblings(), 'close');
+ return this;
+ },
+ getElement: function () {
+ return this.element;
+ },
+ addChild: function (child) {
+ var firstChild = null,
+ lastChild = null;
+ if (this.hasChildren()) {
+ lastChild = this.getLastChild();
+ lastChild.next = child;
+ firstChild = this.getFirstChild();
+ firstChild.prev = child;
+ }
+ child.parent = this;
+ child.next = firstChild;
+ child.prev = lastChild;
+ this.children.push(child);
+ return this;
+ },
+ getChildren: function () {
+ // Returns the copy.
+ return this.children.concat();
+ },
+ hasChildren: function () {
+ return this.getChildren().length > 0;
+ },
+ getFirstChild: function () {
+ return _.first(this.children);
+ },
+ getLastChild: function () {
+ return _.last(this.children);
+ },
+ bindEvent: function (element, events, handler) {
+ $(element).on(this.addNamespace(events), handler);
+ return this;
+ },
+ getNext: function () {
+ var item = this.next;
+ while (item.isHidden() && this.id !== item.id) {
+ item = item.next;
+ }
+ return item;
+ },
+ getPrev: function () {
+ var item = this.prev;
+ while (item.isHidden() && this.id !== item.id) {
+ item = item.prev;
+ }
+ return item;
+ },
+ createElement: function () {
+ return null;
+ },
+ getRoot: function () {
+ var item = this;
+ while (item.parent) {
+ item = item.parent;
+ }
+ return item;
+ },
+ populateElement: function () {
+ },
+ focus: function () {
+ this.getElement().focus();
+ this.closeSiblings();
+ return this;
+ },
+ isHidden: function () {
+ return this.getElement().is(':hidden');
+ },
+ getSiblings: function () {
+ var items = [],
+ item = this;
+ while (item.next && item.next.id !== this.id) {
+ item = item.next;
+ items.push(item);
+ }
+ return items;
+ },
+ select: function () {
+ },
+ unselect: function () {
+ },
+ setLabel: function () {
+ },
+ itemHandler: function () {
+ },
+ keyDownHandler: function () {
+ },
+ delegateEvents: function () {
+ },
+ undelegateEvents: function () {
+ this.getElement().off('.' + this.id);
+ },
+ addNamespace: function (events) {
+ return _.map(events.split(/\s+/), function (event) {
+ return event + '.' + this.id;
+ }, this).join(' ');
+ }
+});
+
+const AbstractMenu = AbstractItem.extend({
+ delegateEvents: function () {
+ this.bindEvent(this.getElement(), 'keydown mouseleave mouseover', this.itemHandler.bind(this))
+ .bindEvent(this.getElement(), 'contextmenu', function (event) {
+ event.preventDefault();
+ });
+ return this;
+ },
+
+ populateElement: function () {
+ var fragment = document.createDocumentFragment();
+
+ _.each(this.getChildren(), function (child) {
+ fragment.appendChild(child.populateElement()[0]);
+ }, this);
+
+ this.appendContent([fragment]);
+ this.isRendered = true;
+ return this.getElement();
+ },
+
+ close: function () {
+ this.closeChildren();
+ this.getElement().removeClass('is-opened');
+ return this;
+ },
+
+ closeChildren: function () {
+ _.invoke(this.getChildren(), 'close');
+ return this;
+ },
+
+ itemHandler: function (event) {
+ event.preventDefault();
+ var item = $(event.target).data('menu');
+ // eslint-disable-next-line default-case
+ switch (event.type) {
+ case 'keydown':
+ this.keyDownHandler.call(this, event, item);
+ break;
+ case 'mouseover':
+ this.mouseOverHandler.call(this, event, item);
+ break;
+ case 'mouseleave':
+ this.mouseLeaveHandler.call(this, event, item);
+ break;
+ }
+ },
+
+ keyDownHandler: function () {
+ },
+ mouseOverHandler: function () {
+ },
+ mouseLeaveHandler: function () {
+ }
+});
+
+const Menu = AbstractMenu.extend({
+ initialize: function (options, contextmenuElement, container) {
+ this.contextmenuElement = $(contextmenuElement);
+ this.container = $(container);
+ this.overlay = this.getOverlay();
+ AbstractMenu.prototype.initialize.apply(this, arguments);
+ this.build(this, this.options.items);
+ },
+
+ createElement: function () {
+ return $('', {
+ class: ['contextmenu', this.options.prefix + 'contextmenu'].join(' '),
+ role: 'menu',
+ tabindex: -1
+ });
+ },
+
+ delegateEvents: function () {
+ AbstractMenu.prototype.delegateEvents.call(this);
+ this.bindEvent(this.contextmenuElement, 'contextmenu', this.contextmenuHandler.bind(this))
+ .bindEvent(window, 'resize', _.debounce(this.close.bind(this), 100));
+ return this;
+ },
+
+ destroy: function () {
+ AbstractMenu.prototype.destroy.call(this);
+ this.overlay.destroy();
+ this.contextmenuElement.removeData('contextmenu');
+ return this;
+ },
+
+ undelegateEvents: function () {
+ AbstractMenu.prototype.undelegateEvents.call(this);
+ this.contextmenuElement.off(this.addNamespace('contextmenu'));
+ this.overlay.undelegateEvents();
+ return this;
+ },
+
+ appendContent: function (content) {
+ var $content = $(content);
+ this.getElement().append($content);
+ return this;
+ },
+
+ addChild: function () {
+ AbstractMenu.prototype.addChild.apply(this, arguments);
+ this.next = this.getFirstChild();
+ this.prev = this.getLastChild();
+ return this;
+ },
+
+ build: function (container, items) {
+ _.each(items, function (item) {
+ var child;
+ if (_.has(item, 'items')) {
+ child = this.build((new Submenu(item, this.contextmenuElement)), item.items);
+ } else {
+ child = new MenuItem(item);
+ }
+ container.addChild(child);
+ }, this);
+ return container;
+ },
+
+ focus: function () {
+ this.getElement().focus();
+ return this;
+ },
+
+ open: function () {
+ var $menu = (this.isRendered) ? this.getElement() : this.populateElement();
+ this.container.append($menu);
+ AbstractItem.prototype.open.call(this);
+ this.overlay.show(this.container);
+ return this;
+ },
+
+ close: function () {
+ AbstractMenu.prototype.close.call(this);
+ this.getElement().detach();
+ this.overlay.hide();
+ return this;
+ },
+
+ position: function (event) {
+ this.getElement().position({
+ my: 'left top',
+ of: event,
+ collision: 'flipfit flipfit',
+ within: this.contextmenuElement
+ });
+
+ return this;
+ },
+
+ pointInContainerBox: function (x, y) {
+ var containerOffset = this.contextmenuElement.offset(),
+ containerBox = {
+ x0: containerOffset.left,
+ y0: containerOffset.top,
+ x1: containerOffset.left + this.contextmenuElement.outerWidth(),
+ y1: containerOffset.top + this.contextmenuElement.outerHeight()
+ };
+ return containerBox.x0 <= x && x <= containerBox.x1 && containerBox.y0 <= y && y <= containerBox.y1;
+ },
+
+ getOverlay: function () {
+ return new Overlay(
+ this.close.bind(this),
+ function (event) {
+ event.preventDefault();
+ if (this.pointInContainerBox(event.pageX, event.pageY)) {
+ this.position(event).focus();
+ this.closeChildren();
+ } else {
+ this.close();
+ }
+ }.bind(this)
+ );
+ },
+
+ contextmenuHandler: function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.open().position(event).focus();
+ },
+
+ keyDownHandler: function (event, item) {
+ var KEY = $.ui.keyCode,
+ keyCode = event.keyCode;
+
+ // eslint-disable-next-line default-case
+ switch (keyCode) {
+ case KEY.UP:
+ item.getPrev().focus();
+ event.stopPropagation();
+ break;
+ case KEY.DOWN:
+ item.getNext().focus();
+ event.stopPropagation();
+ break;
+ case KEY.TAB:
+ event.stopPropagation();
+ break;
+ case KEY.ESCAPE:
+ this.close();
+ break;
+ }
+
+ return false;
+ }
+});
+
+const Overlay = Component.extend({
+ ns: '.overlay',
+ initialize: function (clickHandler, contextmenuHandler) {
+ this.element = $('', {
+ class: 'overlay'
+ });
+ this.clickHandler = clickHandler;
+ this.contextmenuHandler = contextmenuHandler;
+ },
+
+ destroy: function () {
+ this.getElement().remove();
+ this.undelegateEvents();
+ },
+
+ getElement: function () {
+ return this.element;
+ },
+
+ hide: function () {
+ this.getElement().detach();
+ this.undelegateEvents();
+ return this;
+ },
+
+ show: function (container) {
+ var $elem = $(this.getElement());
+ $(container).append($elem);
+ this.delegateEvents();
+ return this;
+ },
+
+ delegateEvents: function () {
+ var self = this;
+ $(document)
+ .on('click' + this.ns, function () {
+ if (_.isFunction(self.clickHandler)) {
+ self.clickHandler.apply(this, arguments);
+ }
+ self.hide();
+ })
+ .on('contextmenu' + this.ns, function () {
+ if (_.isFunction(self.contextmenuHandler)) {
+ self.contextmenuHandler.apply(this, arguments);
+ }
+ });
+ return this;
+ },
+
+ undelegateEvents: function () {
+ $(document).off(this.ns);
+ return this;
+ }
+});
+
+const Submenu = AbstractMenu.extend({
+ initialize: function (options, contextmenuElement) {
+ this.contextmenuElement = contextmenuElement;
+ AbstractMenu.prototype.initialize.apply(this, arguments);
+ },
+
+ createElement: function () {
+ var $spanElem,
+ $listElem,
+ $element = $('', {
+ class: ['submenu-item', 'menu-item', this.options.prefix + 'submenu-item'].join(' '),
+ 'aria-expanded': 'false',
+ 'aria-haspopup': 'true',
+ 'aria-labelledby': 'submenu-item-label-' + this.id,
+ role: 'menuitem',
+ tabindex: -1
+ });
+
+ $spanElem = $('', {
+ id: 'submenu-item-label-' + this.id,
+ text: this.options.label
+ });
+ this.label = $spanElem.appendTo($element);
+
+ $listElem = $('', {
+ class: ['submenu', this.options.prefix + 'submenu'].join(' '),
+ role: 'menu'
+ });
+
+ this.list = $listElem.appendTo($element);
+
+ return $element;
+ },
+
+ appendContent: function (content) {
+ var $content = $(content);
+ this.list.append($content);
+ return this;
+ },
+
+ setLabel: function (label) {
+ this.label.text(label);
+ return this;
+ },
+
+ openKeyboard: function () {
+ if (this.hasChildren()) {
+ this.open();
+ this.getFirstChild().focus();
+ }
+ return this;
+ },
+
+ keyDownHandler: function (event) {
+ var KEY = $.ui.keyCode,
+ keyCode = event.keyCode;
+
+ // eslint-disable-next-line default-case
+ switch (keyCode) {
+ case KEY.LEFT:
+ this.close().focus();
+ event.stopPropagation();
+ break;
+ case KEY.RIGHT:
+ case KEY.ENTER:
+ case KEY.SPACE:
+ this.openKeyboard();
+ event.stopPropagation();
+ break;
+ }
+
+ return false;
+ },
+
+ open: function () {
+ AbstractMenu.prototype.open.call(this);
+ this.getElement().attr({'aria-expanded': 'true'});
+ this.position();
+ return this;
+ },
+
+ close: function () {
+ AbstractMenu.prototype.close.call(this);
+ this.getElement().attr({'aria-expanded': 'false'});
+ return this;
+ },
+
+ position: function () {
+ this.list.position({
+ my: 'left top',
+ at: 'right top',
+ of: this.getElement(),
+ collision: 'flipfit flipfit',
+ within: this.contextmenuElement
+ });
+ return this;
+ },
+
+ mouseOverHandler: function () {
+ clearTimeout(this.timer);
+ this.timer = setTimeout(this.open.bind(this), 200);
+ this.focus();
+ },
+
+ mouseLeaveHandler: function () {
+ clearTimeout(this.timer);
+ this.timer = setTimeout(this.close.bind(this), 200);
+ this.focus();
+ }
+});
+
+const MenuItem = AbstractItem.extend({
+ createElement: function () {
+ var classNames = [
+ 'menu-item', this.options.prefix + 'menu-item',
+ this.options.isSelected ? 'is-selected' : ''
+ ].join(' ');
+
+ return $('', {
+ class: classNames,
+ 'aria-selected': this.options.isSelected ? 'true' : 'false',
+ role: 'menuitem',
+ tabindex: -1,
+ text: this.options.label
+ });
+ },
+
+ populateElement: function () {
+ return this.getElement();
+ },
+
+ delegateEvents: function () {
+ this.bindEvent(this.getElement(), 'click keydown contextmenu mouseover', this.itemHandler.bind(this));
+ return this;
+ },
+
+ setLabel: function (label) {
+ this.getElement().text(label);
+ return this;
+ },
+
+ select: function (event) {
+ this.options.callback.call(this, event, this, this.options);
+ this.getElement()
+ .addClass('is-selected')
+ .attr({'aria-selected': 'true'});
+ _.invoke(this.getSiblings(), 'unselect');
+ // Hide the menu.
+ this.getRoot().close();
+ return this;
+ },
+
+ unselect: function () {
+ this.getElement()
+ .removeClass('is-selected')
+ .attr({'aria-selected': 'false'});
+ return this;
+ },
+
+ itemHandler: function (event) {
+ event.preventDefault();
+ // eslint-disable-next-line default-case
+ switch (event.type) {
+ case 'contextmenu':
+ case 'click':
+ this.select();
+ break;
+ case 'mouseover':
+ this.focus();
+ event.stopPropagation();
+ break;
+ case 'keydown':
+ this.keyDownHandler.call(this, event, this);
+ break;
+ }
+ },
+
+ keyDownHandler: function (event) {
+ var KEY = $.ui.keyCode,
+ keyCode = event.keyCode;
+
+ // eslint-disable-next-line default-case
+ switch (keyCode) {
+ case KEY.RIGHT:
+ event.stopPropagation();
+ break;
+ case KEY.ENTER:
+ case KEY.SPACE:
+ this.select();
+ event.stopPropagation();
+ break;
+ }
+
+ return false;
+ }
+});
+
+const VideoContextMenu = (state, i18n) => {
+ const speedCallback = (event, menuitem, options) => {
+ var speed = parseFloat(options.label);
+ state.videoCommands.execute('speed', speed);
+ }
+ const options = {
+ items: [{
+ label: i18n.Play,
+ callback: function () {
+ state.videoCommands.execute('togglePlayback');
+ },
+ initialize: function (menuitem) {
+ state.el.on({
+ play: function () {
+ menuitem.setLabel(i18n.Pause);
+ },
+ pause: function () {
+ menuitem.setLabel(i18n.Play);
+ }
+ });
+ }
+ }, {
+ label: state.videoVolumeControl.getMuteStatus() ? i18n.Unmute : i18n.Mute,
+ callback: function () {
+ state.videoCommands.execute('toggleMute');
+ },
+ initialize: function (menuitem) {
+ state.el.on({
+ volumechange: function () {
+ if (state.videoVolumeControl.getMuteStatus()) {
+ menuitem.setLabel(i18n.Unmute);
+ } else {
+ menuitem.setLabel(i18n.Mute);
+ }
+ }
+ });
+ }
+ }, {
+ label: i18n['Fill browser'],
+ callback: function () {
+ state.videoCommands.execute('toggleFullScreen');
+ },
+ initialize: function (menuitem) {
+ state.el.on({
+ fullscreen: function (event, isFullscreen) {
+ if (isFullscreen) {
+ menuitem.setLabel(i18n['Exit full browser']);
+ } else {
+ menuitem.setLabel(i18n['Fill browser']);
+ }
+ }
+ });
+ }
+ }, {
+ label: i18n.Speed,
+ items: _.map(state.speeds, function (speed) {
+ var isSelected = parseFloat(speed) === state.speed;
+ return {
+ label: speed + 'x', callback: speedCallback, speed: speed, isSelected: isSelected
+ };
+ }),
+ initialize: function (menuitem) {
+ state.el.on({
+ speedchange: function (event, speed) {
+ // eslint-disable-next-line no-shadow
+ var item = menuitem.getChildren().filter(function (item) {
+ return item.options.speed === speed;
+ })[0];
+ if (item) {
+ item.select();
+ }
+ }
+ });
+ }
+ }
+ ]
+ };
+
+ // eslint-disable-next-line no-shadow
+ $.fn.contextmenu = (container, options) => {
+ return this.each(function () {
+ $(this).data('contextmenu', new Menu(options, this, container));
+ });
+ };
+
+ if (!state.isYoutubeType()) {
+ state.el.find('video').contextmenu(state.el, options);
+ state.el.on('destroy', function () {
+ var contextmenu = $(this).find('video').data('contextmenu');
+ if (contextmenu) {
+ contextmenu.destroy();
+ }
+ });
+ }
+
+ return $.Deferred().resolve().promise();
+}
+
+export default VideoContextMenu
diff --git a/xmodule/assets/video/public/js/video_speed_control.js b/xmodule/assets/video/public/js/video_speed_control.js
new file mode 100644
index 000000000000..37708af72889
--- /dev/null
+++ b/xmodule/assets/video/public/js/video_speed_control.js
@@ -0,0 +1,417 @@
+'use strict';
+
+import Iterator from 'video/00_iterator.js';
+import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
+
+/**
+ * Video speed control module.
+ * @exports video/08_video_speed_control.js
+ * @constructor
+ * @param {object} state The object containing the state of the video player.
+ * @return {jquery Promise}
+ */
+const VideoSpeedControl = (state) => {
+ if (!(this instanceof VideoSpeedControl)) {
+ return new VideoSpeedControl(state);
+ }
+
+ _.bindAll(this, 'onSetSpeed', 'onRenderSpeed', 'clickLinkHandler',
+ 'keyDownLinkHandler', 'mouseEnterHandler', 'mouseLeaveHandler',
+ 'clickMenuHandler', 'keyDownMenuHandler', 'destroy'
+ );
+ this.state = state;
+ this.state.videoSpeedControl = this;
+ this.initialize();
+
+ return $.Deferred().resolve().promise();
+};
+
+VideoSpeedControl.prototype = {
+ template: [
+ '
',
+ '
',
+ gettext('Press UP to enter the speed menu then use the UP and DOWN arrow keys to navigate the different speeds, then press ENTER to change to the selected speed.'), // eslint-disable-line max-len, indent
+ '
',
+ '',
+ '',
+ '
'
+ ].join(''),
+
+ destroy: function () {
+ this.el.off({
+ mouseenter: this.mouseEnterHandler,
+ mouseleave: this.mouseLeaveHandler,
+ click: this.clickMenuHandler,
+ keydown: this.keyDownMenuHandler
+ });
+
+ this.state.el.off({
+ 'speed:set': this.onSetSpeed,
+ 'speed:render': this.onRenderSpeed
+ });
+ this.closeMenu(true);
+ this.speedsContainer.remove();
+ this.el.remove();
+ delete this.state.videoSpeedControl;
+ },
+
+ /** Initializes the module. */
+ initialize: function () {
+ var state = this.state;
+
+ if (!this.isPlaybackRatesSupported(state)) {
+ console.log(
+ '[Video info]: playbackRate is not supported.'
+ );
+
+ return false;
+ }
+ this.el = $(this.template);
+ this.speedsContainer = this.el.find('.video-speeds');
+ this.speedButton = this.el.find('.speed-button');
+ this.render(state.speeds, state.speed);
+ this.setSpeed(state.speed, true, true);
+ this.bindHandlers();
+
+ return true;
+ },
+
+ /**
+ * Creates any necessary DOM elements, attach them, and set their,
+ * initial configuration.
+ * @param {array} speeds List of speeds available for the player.
+ * @param {string} currentSpeed The current speed set to the player.
+ */
+ render: function (speeds, currentSpeed) {
+ var speedsContainer = this.speedsContainer,
+ reversedSpeeds = speeds.concat().reverse(),
+ instructionsId = 'speed-instructions-' + this.state.id,
+ speedsList = $.map(reversedSpeeds, function (speed) {
+ return HtmlUtils.interpolateHtml(
+ HtmlUtils.HTML(
+ [
+ '
',
+ '',
+ '
'
+ ].join('')
+ ),
+ {
+ speed: speed
+ }
+ ).toString();
+ });
+
+ HtmlUtils.setHtml(
+ speedsContainer,
+ HtmlUtils.HTML(speedsList)
+ );
+ this.speedLinks = new Iterator(speedsContainer.find('.speed-option'));
+ HtmlUtils.prepend(
+ this.state.el.find('.secondary-controls'),
+ HtmlUtils.HTML(this.el)
+ );
+ this.setActiveSpeed(currentSpeed);
+
+ // set dynamic id for instruction element to avoid collisions
+ this.el.find('.instructions').attr('id', instructionsId);
+ this.speedButton.attr('aria-describedby', instructionsId);
+ },
+
+ /**
+ * Bind any necessary function callbacks to DOM events (click,
+ * mousemove, etc.).
+ */
+ bindHandlers: function () {
+ // Attach various events handlers to the speed menu button.
+ this.el.on({
+ mouseenter: this.mouseEnterHandler,
+ mouseleave: this.mouseLeaveHandler,
+ click: this.openMenu,
+ keydown: this.keyDownMenuHandler
+ });
+
+ // Attach click and keydown event handlers to the individual speed
+ // entries.
+ this.speedsContainer.on({
+ click: this.clickLinkHandler,
+ keydown: this.keyDownLinkHandler
+ }, '.speed-option');
+
+ this.state.el.on({
+ 'speed:set': this.onSetSpeed,
+ 'speed:render': this.onRenderSpeed
+ });
+ this.state.el.on('destroy', this.destroy);
+ },
+
+ onSetSpeed: function (event, speed) {
+ this.setSpeed(speed, true);
+ },
+
+ onRenderSpeed: function (event, speeds, currentSpeed) {
+ this.render(speeds, currentSpeed);
+ },
+
+ /**
+ * Check if playbackRate supports by browser. If browser supports, 1.0
+ * should be returned by playbackRate property. In this case, function
+ * return True. Otherwise, False will be returned.
+ * iOS doesn't support speed change.
+ * @param {object} state The object containing the state of the video
+ * player.
+ * @return {boolean}
+ * true: Browser support playbackRate functionality.
+ * false: Browser doesn't support playbackRate functionality.
+ */
+ isPlaybackRatesSupported: function (state) {
+ var isHtml5 = state.videoType === 'html5',
+ isTouch = state.isTouch,
+ video = document.createElement('video');
+
+ // eslint-disable-next-line no-extra-boolean-cast
+ return !isTouch || (isHtml5 && !Boolean(video.playbackRate));
+ },
+
+ /**
+ * Opens speed menu.
+ * @param {boolean} [bindEvent] Click event will be attached on window.
+ */
+ openMenu: function (bindEvent) {
+ // When speed entries have focus, the menu stays open on
+ // mouseleave. A clickHandler is added to the window
+ // element to have clicks close the menu when they happen
+ // outside of it.
+ if (bindEvent) {
+ $(window).on('click.speedMenu', this.clickMenuHandler);
+ }
+
+ this.el.addClass('is-opened');
+ this.speedButton
+ .attr('tabindex', -1)
+ .attr('aria-expanded', 'true');
+ },
+
+ /**
+ * Closes speed menu.
+ * @param {boolean} [unBindEvent] Click event will be detached from window.
+ */
+ closeMenu: function (unBindEvent) {
+ // Remove the previously added clickHandler from window element.
+ if (unBindEvent) {
+ $(window).off('click.speedMenu');
+ }
+
+ this.el.removeClass('is-opened');
+ this.speedButton
+ .attr('tabindex', 0)
+ .attr('aria-expanded', 'false');
+ },
+
+ /**
+ * Sets new current speed for the speed control and triggers `speedchange`
+ * event if needed.
+ * @param {string|number} speed Speed to be set.
+ * @param {boolean} [silent] Sets the new speed without triggering
+ * `speedchange` event.
+ * @param {boolean} [forceUpdate] Updates the speed even if it's
+ * not differs from current speed.
+ */
+ setSpeed: function (speed, silent, forceUpdate) {
+ var newSpeed = this.state.speedToString(speed);
+ if (newSpeed !== this.currentSpeed || forceUpdate) {
+ this.speedsContainer
+ .find('li')
+ .siblings("li[data-speed='" + newSpeed + "']");
+
+ this.speedButton.find('.value').text(newSpeed + 'x');
+ this.currentSpeed = newSpeed;
+
+ if (!silent) {
+ this.el.trigger('speedchange', [newSpeed, this.state.speed]);
+ }
+ }
+
+ this.resetActiveSpeed();
+ this.setActiveSpeed(newSpeed);
+ },
+
+ resetActiveSpeed: function () {
+ var speedOptions = this.speedsContainer.find('li');
+
+ $(speedOptions).each(function (index, el) {
+ $(el).removeClass('is-active')
+ .find('.speed-option')
+ .attr('aria-pressed', 'false');
+ });
+ },
+
+ setActiveSpeed: function (speed) {
+ var speedOption = this.speedsContainer.find('li[data-speed="' + this.state.speedToString(speed) + '"]');
+
+ speedOption.addClass('is-active')
+ .find('.speed-option')
+ .attr('aria-pressed', 'true');
+
+ this.speedButton.attr('title', gettext('Video speed: ') + this.state.speedToString(speed) + 'x');
+ },
+
+ /**
+ * Click event handler for the menu.
+ * @param {jquery Event} event
+ */
+ clickMenuHandler: function () {
+ this.closeMenu();
+
+ return false;
+ },
+
+ /**
+ * Click event handler for speed links.
+ * @param {jquery Event} event
+ */
+ clickLinkHandler: function (event) {
+ var el = $(event.currentTarget).parent(),
+ speed = $(el).data('speed');
+
+ this.resetActiveSpeed();
+ this.setActiveSpeed(speed);
+ this.state.videoCommands.execute('speed', speed);
+ this.closeMenu(true);
+
+ return false;
+ },
+
+ /**
+ * Mouseenter event handler for the menu.
+ * @param {jquery Event} event
+ */
+ mouseEnterHandler: function () {
+ this.openMenu();
+
+ return false;
+ },
+
+ /**
+ * Mouseleave event handler for the menu.
+ * @param {jquery Event} event
+ */
+ mouseLeaveHandler: function () {
+ // Only close the menu is no speed entry has focus.
+ if (!this.speedLinks.list.is(':focus')) {
+ this.closeMenu();
+ }
+
+ return false;
+ },
+
+ /**
+ * Keydown event handler for the menu.
+ * @param {jquery Event} event
+ */
+ keyDownMenuHandler: function (event) {
+ var KEY = $.ui.keyCode,
+ keyCode = event.keyCode;
+
+ // eslint-disable-next-line default-case
+ switch (keyCode) {
+ // Open menu and focus on last element of list above it.
+ case KEY.ENTER:
+ case KEY.SPACE:
+ case KEY.UP:
+ this.openMenu(true);
+ this.speedLinks.last().focus();
+ break;
+ // Close menu.
+ case KEY.ESCAPE:
+ this.closeMenu(true);
+ break;
+ }
+ // We do not stop propagation and default behavior on a TAB
+ // keypress.
+ return event.keyCode === KEY.TAB;
+ },
+
+ /**
+ * Keydown event handler for speed links.
+ * @param {jquery Event} event
+ */
+ keyDownLinkHandler: function (event) {
+ // ALT key is used to change (alternate) the function of
+ // other pressed keys. In this, do nothing.
+ if (event.altKey) {
+ return true;
+ }
+
+ var KEY = $.ui.keyCode,
+ self = this,
+ parent = $(event.currentTarget).parent(),
+ index = parent.index(),
+ speed = parent.data('speed');
+
+ // eslint-disable-next-line default-case
+ switch (event.keyCode) {
+ // Close menu.
+ case KEY.TAB:
+ // Closes menu after 25ms delay to change `tabindex` after
+ // finishing default behavior.
+ setTimeout(function () {
+ self.closeMenu(true);
+ }, 25);
+
+ return true;
+ // Close menu and give focus to speed control.
+ case KEY.ESCAPE:
+ this.closeMenu(true);
+ this.speedButton.focus();
+
+ return false;
+ // Scroll up menu, wrapping at the top. Keep menu open.
+ case KEY.UP:
+ // Shift + Arrows keyboard shortcut might be used by
+ // screen readers. In this, do nothing.
+ if (event.shiftKey) {
+ return true;
+ }
+
+ this.speedLinks.prev(index).focus();
+ return false;
+ // Scroll down menu, wrapping at the bottom. Keep menu
+ // open.
+ case KEY.DOWN:
+ // Shift + Arrows keyboard shortcut might be used by
+ // screen readers. In this, do nothing.
+ if (event.shiftKey) {
+ return true;
+ }
+
+ this.speedLinks.next(index).focus();
+ return false;
+ // Close menu, give focus to speed control and change
+ // speed.
+ case KEY.ENTER:
+ case KEY.SPACE:
+ this.closeMenu(true);
+ this.speedButton.focus();
+ this.setSpeed(this.state.speedToString(speed));
+
+ return false;
+ }
+
+ return true;
+ }
+};
+
+export default VideoSpeedControl;
diff --git a/xmodule/assets/video/public/js/video_transcript_feedback.js b/xmodule/assets/video/public/js/video_transcript_feedback.js
new file mode 100644
index 000000000000..ad4888853d7f
--- /dev/null
+++ b/xmodule/assets/video/public/js/video_transcript_feedback.js
@@ -0,0 +1,241 @@
+// VideoTranscriptFeedbackHandler module.
+'use strict';
+
+import _ from 'underscore';
+
+/**
+ * @desc VideoTranscriptFeedbackHandler 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.
+ *
+ */
+
+const VideoTranscriptFeedback = (state) => {
+ if (!(this instanceof VideoTranscriptFeedback)) {
+ return new VideoTranscriptFeedback(state);
+ }
+
+ _.bindAll(this, 'destroy', 'getFeedbackForCurrentTranscript', 'markAsPositiveFeedback', 'markAsNegativeFeedback', 'markAsEmptyFeedback',
+ 'selectThumbsUp', 'selectThumbsDown', 'unselectThumbsUp', 'unselectThumbsDown', 'thumbsUpClickHandler', 'thumbsDownClickHandler',
+ 'sendFeedbackForCurrentTranscript', 'onHideLanguageMenu', 'getCurrentLanguage', 'loadAndSetVisibility', 'showWidget', 'hideWidget'
+ );
+
+ this.state = state;
+ this.state.videoTranscriptFeedback = this;
+ this.currentTranscriptLanguage = this.state.lang;
+ this.transcriptLanguages = this.state.config.transcriptLanguages;
+
+ if (this.state.el.find('.wrapper-transcript-feedback').length) {
+ this.initialize();
+ }
+
+ return false;
+};
+
+VideoTranscriptFeedback.prototype = {
+ destroy: function () {
+ this.state.el.off(this.events);
+ },
+
+ initialize: function () {
+ this.el = this.state.el.find('.wrapper-transcript-feedback');
+
+ this.videoId = this.el.data('video-id');
+ this.userId = this.el.data('user-id');
+ this.aiTranslationsUrl = this.state.config.aiTranslationsUrl;
+
+ this.thumbsUpButton = this.el.find('.thumbs-up-btn');
+ this.thumbsDownButton = this.el.find('.thumbs-down-btn');
+ this.thumbsUpButton.on('click', this.thumbsUpClickHandler);
+ this.thumbsDownButton.on('click', this.thumbsDownClickHandler);
+
+ this.events = {
+ 'language_menu:hide': this.onHideLanguageMenu,
+ destroy: this.destroy
+ };
+ this.loadAndSetVisibility();
+ this.bindHandlers();
+ },
+
+ bindHandlers: function () {
+ this.state.el.on(this.events);
+ },
+
+ getFeedbackForCurrentTranscript: function () {
+ var self = this;
+ var url = self.aiTranslationsUrl + '/transcript-feedback' + '?transcript_language=' + self.currentTranscriptLanguage + '&video_id=' + self.videoId + '&user_id=' + self.userId;
+
+ $.ajax({
+ url: url,
+ type: 'GET',
+ success: function (data) {
+ if (data && data.value === true) {
+ self.markAsPositiveFeedback();
+ self.currentFeedback = true;
+ } else {
+ if (data && data.value === false) {
+ self.markAsNegativeFeedback();
+ self.currentFeedback = false;
+ } else {
+ self.markAsEmptyFeedback();
+ self.currentFeedback = null;
+ }
+ }
+ },
+ error: function (error) {
+ self.markAsEmptyFeedback();
+ self.currentFeedback = null;
+ }
+ });
+ },
+
+ markAsPositiveFeedback: function () {
+ this.selectThumbsUp();
+ this.unselectThumbsDown();
+ },
+
+ markAsNegativeFeedback: function () {
+ this.selectThumbsDown();
+ this.unselectThumbsUp();
+ },
+
+ markAsEmptyFeedback: function () {
+ this.unselectThumbsUp();
+ this.unselectThumbsDown();
+ },
+
+ selectThumbsUp: function () {
+ var thumbsUpIcon = this.thumbsUpButton.find('.thumbs-up-icon');
+ if (thumbsUpIcon[0].classList.contains('fa-thumbs-o-up')) {
+ thumbsUpIcon[0].classList.remove("fa-thumbs-o-up");
+ thumbsUpIcon[0].classList.add("fa-thumbs-up");
+ }
+ },
+
+ selectThumbsDown: function () {
+ var thumbsDownIcon = this.thumbsDownButton.find('.thumbs-down-icon');
+ if (thumbsDownIcon[0].classList.contains('fa-thumbs-o-down')) {
+ thumbsDownIcon[0].classList.remove("fa-thumbs-o-down");
+ thumbsDownIcon[0].classList.add("fa-thumbs-down");
+ }
+ },
+
+ unselectThumbsUp: function () {
+ var thumbsUpIcon = this.thumbsUpButton.find('.thumbs-up-icon');
+ if (thumbsUpIcon[0].classList.contains('fa-thumbs-up')) {
+ thumbsUpIcon[0].classList.remove("fa-thumbs-up");
+ thumbsUpIcon[0].classList.add("fa-thumbs-o-up");
+ }
+ },
+
+ unselectThumbsDown: function () {
+ var thumbsDownIcon = this.thumbsDownButton.find('.thumbs-down-icon');
+ if (thumbsDownIcon[0].classList.contains('fa-thumbs-down')) {
+ thumbsDownIcon[0].classList.remove("fa-thumbs-down");
+ thumbsDownIcon[0].classList.add("fa-thumbs-o-down");
+ }
+ },
+
+ thumbsUpClickHandler: function () {
+ if (this.currentFeedback) {
+ this.sendFeedbackForCurrentTranscript(null);
+ } else {
+ this.sendFeedbackForCurrentTranscript(true);
+ }
+ },
+
+ thumbsDownClickHandler: function () {
+ if (this.currentFeedback === false) {
+ this.sendFeedbackForCurrentTranscript(null);
+ } else {
+ this.sendFeedbackForCurrentTranscript(false);
+ }
+ },
+
+ sendFeedbackForCurrentTranscript: function (feedbackValue) {
+ var self = this;
+ var url = self.aiTranslationsUrl + '/transcript-feedback/';
+ $.ajax({
+ url: url,
+ type: 'POST',
+ dataType: 'json',
+ data: {
+ transcript_language: self.currentTranscriptLanguage,
+ video_id: self.videoId,
+ user_id: self.userId,
+ value: feedbackValue,
+ },
+ success: function (data) {
+ if (data && data.value === true) {
+ self.markAsPositiveFeedback();
+ self.currentFeedback = true;
+ } else {
+ if (data && data.value === false) {
+ self.markAsNegativeFeedback();
+ self.currentFeedback = false;
+ } else {
+ self.markAsEmptyFeedback();
+ self.currentFeedback = null;
+ }
+ }
+ },
+ error: function () {
+ self.markAsEmptyFeedback();
+ self.currentFeedback = null;
+ }
+ });
+ },
+
+ onHideLanguageMenu: function () {
+ var newLanguageSelected = this.getCurrentLanguage();
+ if (this.currentTranscriptLanguage !== newLanguageSelected) {
+ this.currentTranscriptLanguage = this.getCurrentLanguage();
+ this.loadAndSetVisibility();
+ }
+ },
+
+ getCurrentLanguage: function () {
+ var language = this.state.lang;
+ return language;
+ },
+
+ loadAndSetVisibility: function () {
+ var self = this;
+ var url = self.aiTranslationsUrl + '/video-transcript' + '?transcript_language=' + self.currentTranscriptLanguage + '&video_id=' + self.videoId;
+
+ $.ajax({
+ url: url,
+ type: 'GET',
+ async: false,
+ success: function (data) {
+ if (data && data.status === 'Completed') {
+ self.showWidget();
+ self.getFeedbackForCurrentTranscript();
+ } else {
+ self.hideWidget();
+ }
+ },
+ error: function (error) {
+ self.hideWidget();
+ }
+ });
+ },
+
+ showWidget: function () {
+ this.el.show();
+ },
+
+ hideWidget: function () {
+ this.el.hide();
+ }
+};
+
+// Export as ES6 default
+export default VideoTranscriptFeedback;
diff --git a/xmodule/assets/video/public/js/video_volume_control.js b/xmodule/assets/video/public/js/video_volume_control.js
new file mode 100644
index 000000000000..e8e2a41f4e84
--- /dev/null
+++ b/xmodule/assets/video/public/js/video_volume_control.js
@@ -0,0 +1,554 @@
+'use strict';
+
+import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
+
+/**
+ * Video volume control module.
+ * @exports video/07_video_volume_control.js
+ * @constructor
+ * @param {Object} state The object containing the state of the video
+ * @param {Object} i18n The object containing strings with translations.
+ * @return {jquery Promise}
+ */
+const VideoVolumeControl = function (state, i18n) {
+ if (!(this instanceof VideoVolumeControl)) {
+ return new VideoVolumeControl(state, i18n);
+ }
+
+ _.bindAll(this, 'keyDownHandler', 'updateVolumeSilently',
+ 'onVolumeChangeHandler', 'openMenu', 'closeMenu',
+ 'toggleMuteHandler', 'keyDownButtonHandler', 'destroy'
+ );
+ this.state = state;
+ this.state.videoVolumeControl = this;
+ this.i18n = i18n;
+ this.initialize();
+
+ return $.Deferred().resolve().promise();
+};
+
+
+VideoVolumeControl.prototype = {
+ /** Minimum value for the volume slider. */
+ min: 0,
+ /** Maximum value for the volume slider. */
+ max: 100,
+ /** Step to increase/decrease volume level via keyboard. */
+ step: 20,
+
+ videoVolumeControlHtml: HtmlUtils.interpolateHtml(
+ HtmlUtils.HTML([
+ '
',
+ '
',
+ '{volumeInstructions}',
+ '
',
+ '',
+ '
',
+ '',
+ '
',
+ '
'].join('')),
+ {
+ volumeInstructions: gettext('Click on this button to mute or unmute this video or press UP or DOWN buttons to increase or decrease volume level.'), // eslint-disable-line max-len
+ adjustVideoVolume: gettext('Adjust video volume'),
+ volumeText: gettext('Volume')
+ }
+ ),
+
+ destroy: function () {
+ this.volumeSlider.slider('destroy');
+ this.state.el.find('iframe').removeAttr('tabindex');
+ this.a11y.destroy();
+ // eslint-disable-next-line no-multi-assign
+ this.cookie = this.a11y = null;
+ this.closeMenu();
+
+ this.state.el
+ .off('play.volume')
+ .off({
+ keydown: this.keyDownHandler,
+ volumechange: this.onVolumeChangeHandler
+ });
+ this.el.off({
+ mouseenter: this.openMenu,
+ mouseleave: this.closeMenu
+ });
+ this.button.off({
+ mousedown: this.toggleMuteHandler,
+ keydown: this.keyDownButtonHandler,
+ focus: this.openMenu,
+ blur: this.closeMenu
+ });
+ this.el.remove();
+ delete this.state.videoVolumeControl;
+ },
+
+ /** Initializes the module. */
+ initialize: function () {
+ var volume;
+
+ if (this.state.isTouch) {
+ // iOS doesn't support volume change
+ return false;
+ }
+
+ this.el = $(this.videoVolumeControlHtml.toString());
+ // Youtube iframe react on key buttons and has his own handlers.
+ // So, we disallow focusing on iframe.
+ this.state.el.find('iframe').attr('tabindex', -1);
+ this.button = this.el.children('.control');
+ this.cookie = new CookieManager(this.min, this.max);
+ this.a11y = new Accessibility(
+ this.button, this.min, this.max, this.i18n
+ );
+ volume = this.cookie.getVolume();
+ this.storedVolume = this.max;
+
+ this.render();
+ this.bindHandlers();
+ this.setVolume(volume, true, false);
+ this.checkMuteButtonStatus(volume);
+ },
+
+ /**
+ * Creates any necessary DOM elements, attach them, and set their,
+ * initial configuration.
+ */
+ render: function () {
+ var container = this.el.find('.volume-slider'),
+ instructionsId = 'volume-instructions-' + this.state.id;
+
+ HtmlUtils.append(container, HtmlUtils.HTML(''));
+
+ this.volumeSlider = container.slider({
+ orientation: 'vertical',
+ range: 'min',
+ min: this.min,
+ max: this.max,
+ slide: this.onSlideHandler.bind(this)
+ });
+
+ // We provide an independent behavior to adjust volume level.
+ // Therefore, we do not need redundant focusing on slider in TAB
+ // order.
+ container.find('.volume-handle').attr('tabindex', -1);
+ this.state.el.find('.secondary-controls').append(this.el);
+
+ // set dynamic id for instruction element to avoid collisions
+ this.el.find('.instructions').attr('id', instructionsId);
+ this.button.attr('aria-describedby', instructionsId);
+ },
+
+ /** Bind any necessary function callbacks to DOM events. */
+ bindHandlers: function () {
+ this.state.el.on({
+ 'play.volume': _.once(this.updateVolumeSilently),
+ volumechange: this.onVolumeChangeHandler
+ });
+ this.state.el.find('.volume').on({
+ mouseenter: this.openMenu,
+ mouseleave: this.closeMenu
+ });
+ this.button.on({
+ keydown: this.keyDownHandler,
+ click: false,
+ mousedown: this.toggleMuteHandler,
+ focus: this.openMenu,
+ blur: this.closeMenu
+ });
+ this.state.el.on('destroy', this.destroy);
+ },
+
+ /**
+ * Updates volume level without updating view and triggering
+ * `volumechange` event.
+ */
+ updateVolumeSilently: function () {
+ this.state.el.trigger(
+ 'volumechange:silent', [this.getVolume()]
+ );
+ },
+
+ /**
+ * Returns current volume level.
+ * @return {Number}
+ */
+ getVolume: function () {
+ return this.volume;
+ },
+
+ /**
+ * Sets current volume level.
+ * @param {Number} volume Suggested volume level
+ * @param {Boolean} [silent] Sets the new volume level without
+ * triggering `volumechange` event and updating the cookie.
+ * @param {Boolean} [withoutSlider] Disables updating the slider.
+ */
+ setVolume: function (volume, silent, withoutSlider) {
+ if (volume === this.getVolume()) {
+ return false;
+ }
+
+ this.volume = volume;
+ this.a11y.update(this.getVolume());
+
+ if (!withoutSlider) {
+ this.updateSliderView(this.getVolume());
+ }
+
+ if (!silent) {
+ this.cookie.setVolume(this.getVolume());
+ this.state.el.trigger('volumechange', [this.getVolume()]);
+ }
+ },
+
+ /** Increases current volume level using previously defined step. */
+ increaseVolume: function () {
+ var volume = Math.min(this.getVolume() + this.step, this.max);
+
+ this.setVolume(volume, false, false);
+ this.el.find('.volume-slider')
+ .attr('aria-valuenow', volume);
+ },
+
+ /** Decreases current volume level using previously defined step. */
+ decreaseVolume: function () {
+ var volume = Math.max(this.getVolume() - this.step, this.min);
+
+ this.setVolume(volume, false, false);
+ this.el.find('.volume-slider')
+ .attr('aria-valuenow', volume);
+ },
+
+ /** Updates volume slider view. */
+ updateSliderView: function (volume) {
+ this.volumeSlider.slider('value', volume);
+ this.el.find('.volume-slider')
+ .attr('aria-valuenow', volume);
+ },
+
+ /**
+ * Mutes or unmutes volume.
+ * @param {Number} muteStatus Flag to mute/unmute volume.
+ */
+ mute: function (muteStatus) {
+ var volume;
+
+ this.updateMuteButtonView(muteStatus);
+
+ if (muteStatus) {
+ this.storedVolume = this.getVolume() || this.max;
+ }
+
+ volume = muteStatus ? 0 : this.storedVolume;
+ this.setVolume(volume, false, false);
+ this.el.find('.volume-slider')
+ .attr('aria-valuenow', volume);
+ },
+
+ /**
+ * Returns current volume state (is it muted or not?).
+ * @return {Boolean}
+ */
+ getMuteStatus: function () {
+ return this.getVolume() === 0;
+ },
+
+ /**
+ * Updates the volume button view.
+ * @param {Boolean} isMuted Flag to use muted or unmuted view.
+ */
+ updateMuteButtonView: function (isMuted) {
+ var action = isMuted ? 'addClass' : 'removeClass';
+
+ this.el[action]('is-muted');
+
+ if (isMuted) {
+ this.el
+ .find('.control .icon')
+ .removeClass('fa-volume-up')
+ .addClass('fa-volume-off');
+ } else {
+ this.el
+ .find('.control .icon')
+ .removeClass('fa-volume-off')
+ .addClass('fa-volume-up');
+ }
+ },
+
+ /** Toggles the state of the volume button. */
+ toggleMute: function () {
+ this.mute(!this.getMuteStatus());
+ },
+
+ /**
+ * Checks and updates the state of the volume button relatively to
+ * volume level.
+ * @param {Number} volume Volume level.
+ */
+ checkMuteButtonStatus: function (volume) {
+ if (volume <= this.min) {
+ this.updateMuteButtonView(true);
+ this.state.el.off('volumechange.is-muted');
+ this.state.el.on('volumechange.is-muted', _.once(function () {
+ this.updateMuteButtonView(false);
+ }.bind(this)));
+ }
+ },
+
+ /** Opens volume menu. */
+ openMenu: function () {
+ this.el.addClass('is-opened');
+ this.button.attr('aria-expanded', 'true');
+ },
+
+ /** Closes speed menu. */
+ closeMenu: function () {
+ this.el.removeClass('is-opened');
+ this.button.attr('aria-expanded', 'false');
+ },
+
+ /**
+ * Keydown event handler for the video container.
+ * @param {jquery Event} event
+ */
+ keyDownHandler: function (event) {
+ // ALT key is used to change (alternate) the function of
+ // other pressed keys. In this case, do nothing.
+ if (event.altKey) {
+ return true;
+ }
+
+ if ($(event.target).hasClass('ui-slider-handle')) {
+ return true;
+ }
+
+ var KEY = $.ui.keyCode,
+ keyCode = event.keyCode;
+
+ // eslint-disable-next-line default-case
+ switch (keyCode) {
+ case KEY.UP:
+ // Shift + Arrows keyboard shortcut might be used by
+ // screen readers. In this case, do nothing.
+ if (event.shiftKey) {
+ return true;
+ }
+
+ this.increaseVolume();
+ return false;
+ case KEY.DOWN:
+ // Shift + Arrows keyboard shortcut might be used by
+ // screen readers. In this case, do nothing.
+ if (event.shiftKey) {
+ return true;
+ }
+
+ this.decreaseVolume();
+ return false;
+
+ case KEY.SPACE:
+ case KEY.ENTER:
+ // Shift + Enter keyboard shortcut might be used by
+ // screen readers. In this case, do nothing.
+ if (event.shiftKey) {
+ return true;
+ }
+
+ this.toggleMute();
+ return false;
+ }
+
+ return true;
+ },
+
+ /**
+ * Keydown event handler for the volume button.
+ * @param {jquery Event} event
+ */
+ keyDownButtonHandler: function (event) {
+ // ALT key is used to change (alternate) the function of
+ // other pressed keys. In this case, do nothing.
+ if (event.altKey) {
+ return true;
+ }
+
+ var KEY = $.ui.keyCode,
+ keyCode = event.keyCode;
+
+ // eslint-disable-next-line default-case
+ switch (keyCode) {
+ case KEY.ENTER:
+ case KEY.SPACE:
+ this.toggleMute();
+ return false;
+ }
+
+ return true;
+ },
+
+ /**
+ * onSlide callback for the video slider.
+ * @param {jquery Event} event
+ * @param {jqueryuiSlider ui} ui
+ */
+ onSlideHandler: function (event, ui) {
+ this.setVolume(ui.value, false, true);
+ this.el.find('.volume-slider')
+ .attr('aria-valuenow', ui.volume);
+ },
+
+ /**
+ * Mousedown event handler for the volume button.
+ * @param {jquery Event} event
+ */
+ toggleMuteHandler: function (event) {
+ this.toggleMute();
+ event.preventDefault();
+ },
+
+ /**
+ * Volumechange event handler.
+ * @param {jquery Event} event
+ * @param {Number} volume Volume level.
+ */
+ onVolumeChangeHandler: function (event, volume) {
+ this.checkMuteButtonStatus(volume);
+ }
+};
+
+
+/**
+ * Module responsible for the accessibility of volume controls.
+ * @constructor
+ * @private
+ * @param {jquery $} button The volume button.
+ * @param {Number} min Minimum value for the volume slider.
+ * @param {Number} max Maximum value for the volume slider.
+ * @param {Object} i18n The object containing strings with translations.
+ */
+const Accessibility = (button, min, max, i18n) => {
+ this.min = min;
+ this.max = max;
+ this.button = button;
+ this.i18n = i18n;
+
+ this.initialize();
+};
+
+
+Accessibility.prototype = {
+ destroy: function () {
+ this.liveRegion.remove();
+ },
+
+ /** Initializes the module. */
+ initialize: function () {
+ this.liveRegion = $('', {
+ class: 'sr video-live-region',
+ 'aria-hidden': 'false',
+ 'aria-live': 'polite'
+ });
+
+ this.button.after(HtmlUtils.HTML(this.liveRegion).toString());
+ },
+
+ /**
+ * Updates text of the live region.
+ * @param {Number} volume Volume level.
+ */
+ update: function (volume) {
+ this.liveRegion.text([
+ this.getVolumeDescription(volume),
+ this.i18n.Volume + '.'
+ ].join(' '));
+
+ $(this.button).parent().find('.volume-slider')
+ .attr('aria-valuenow', volume);
+ },
+
+ /**
+ * Returns a string describing the level of volume.
+ * @param {Number} volume Volume level.
+ */
+ getVolumeDescription: function (volume) {
+ if (volume === 0) {
+ return this.i18n.Muted;
+ } else if (volume <= 20) {
+ return this.i18n['Very low'];
+ } else if (volume <= 40) {
+ return this.i18n.Low;
+ } else if (volume <= 60) {
+ return this.i18n.Average;
+ } else if (volume <= 80) {
+ return this.i18n.Loud;
+ } else if (volume <= 99) {
+ return this.i18n['Very loud'];
+ }
+
+ return this.i18n.Maximum;
+ }
+};
+
+
+/**
+ * Module responsible for the work with volume cookie.
+ * @constructor
+ * @private
+ * @param {Number} min Minimum value for the volume slider.
+ * @param {Number} max Maximum value for the volume slider.
+ */
+const CookieManager = function (min, max) {
+ this.min = min;
+ this.max = max;
+ this.cookieName = 'video_player_volume_level';
+};
+
+
+CookieManager.prototype = {
+ /**
+ * Returns volume level from the cookie.
+ * @return {Number} Volume level.
+ */
+ getVolume: function () {
+ var volume = parseInt($.cookie(this.cookieName), 10);
+
+ if (_.isFinite(volume)) {
+ volume = Math.max(volume, this.min);
+ volume = Math.min(volume, this.max);
+ } else {
+ volume = this.max;
+ }
+
+ return volume;
+ },
+
+ /**
+ * Updates volume cookie.
+ * @param {Number} volume Volume level.
+ */
+ setVolume: function (value) {
+ $.cookie(this.cookieName, value, {
+ expires: 3650,
+ path: '/'
+ });
+ }
+};
+
+export default VideoVolumeControl;