From ef89e52a06ddf2b96502c8c619fae41e51a3fc0e Mon Sep 17 00:00:00 2001 From: farhan Date: Thu, 17 Apr 2025 11:48:27 +0500 Subject: [PATCH] chore: Adds skip_control.js file # Conflicts: # xmodule/assets/video/public/js/video_block_main.js --- xmodule/assets/video/public/js/time.js | 43 + .../video/public/js/video_block_main.js | 73 +- .../assets/video/public/js/video_caption.js | 1420 +++++++++++++++++ .../video/public/js/video_context_menu.js | 699 ++++++++ .../video/public/js/video_speed_control.js | 417 +++++ .../public/js/video_transcript_feedback.js | 241 +++ .../video/public/js/video_volume_control.js | 554 +++++++ 7 files changed, 3411 insertions(+), 36 deletions(-) create mode 100644 xmodule/assets/video/public/js/time.js create mode 100644 xmodule/assets/video/public/js/video_caption.js create mode 100644 xmodule/assets/video/public/js/video_context_menu.js create mode 100644 xmodule/assets/video/public/js/video_speed_control.js create mode 100644 xmodule/assets/video/public/js/video_transcript_feedback.js create mode 100644 xmodule/assets/video/public/js/video_volume_control.js diff --git a/xmodule/assets/video/public/js/time.js b/xmodule/assets/video/public/js/time.js new file mode 100644 index 000000000000..e9c96571874d --- /dev/null +++ b/xmodule/assets/video/public/js/time.js @@ -0,0 +1,43 @@ +// eslint-disable-next-line no-shadow +function format(time, formatFull) { + var hours, minutes, seconds; + + if (!_.isFinite(time) || time < 0) { + time = 0; + } + + seconds = Math.floor(time); + minutes = Math.floor(seconds / 60); + hours = Math.floor(minutes / 60); + seconds %= 60; + minutes %= 60; + + if (formatFull) { + return '' + _pad(hours) + ':' + _pad(minutes) + ':' + _pad(seconds % 60); + } else if (hours) { + return '' + hours + ':' + _pad(minutes) + ':' + _pad(seconds % 60); + } else { + return '' + minutes + ':' + _pad(seconds % 60); + } +} + +function formatFull(time) { + // The returned value will not be user-facing. So no need for + // internationalization. + return format(time, true); +} + +function convert(time, oldSpeed, newSpeed) { + // eslint-disable-next-line no-mixed-operators + return (time * oldSpeed / newSpeed).toFixed(3); +} + +function _pad(number) { + if (number < 10) { + return '0' + number; + } else { + return '' + number; + } +} + +export default {format, formatFull, convert}; diff --git a/xmodule/assets/video/public/js/video_block_main.js b/xmodule/assets/video/public/js/video_block_main.js index c3f7372fb8dc..902c6e704f21 100644 --- a/xmodule/assets/video/public/js/video_block_main.js +++ b/xmodule/assets/video/public/js/video_block_main.js @@ -4,32 +4,31 @@ import _ from 'underscore'; import {VideoStorage} from './video_storage'; import {VideoPoster} from './poster'; import {VideoTranscriptDownloadHandler} from './video_accessible_menu'; -import {VideoSkipControl} from './skip_control'; -import {VideoPlayPlaceholder} from './play_placeholder'; -import {VideoPlaySkipControl} from './play_skip_control'; -import {VideoPlayPauseControl} from './play_pause_control'; -import {VideoSocialSharingHandler} from './video_social_sharing'; -import {FocusGrabber} from './focus_grabber'; -import {VideoCommands} from "./commands"; -import {VideoEventsBumperPlugin} from "./events_bumper_plugin"; -import {VideoEventsPlugin} from "./events_plugin"; -import {VideoSaveStatePlugin} from "./save_state_plugin"; - // TODO: Uncomment the imports // import { initialize } from './initialize'; // Assuming this function is imported // import { +// FocusGrabber, // VideoControl, +// VideoPlayPlaceholder, +// VideoPlayPauseControl, // VideoProgressSlider, // VideoSpeedControl, // VideoVolumeControl, // VideoQualityControl, // VideoFullScreen, // VideoCaption, +// VideoCommands, // VideoContextMenu, +// VideoSaveStatePlugin, +// VideoEventsPlugin, // VideoCompletionHandler, // VideoTranscriptFeedback, // VideoAutoAdvanceControl, +// VideoPlaySkipControl, +// VideoSkipControl, +// VideoEventsBumperPlugin, +// VideoSocialSharing, // VideoBumper, // } from './video_modules'; // Assuming all necessary modules are grouped here @@ -58,36 +57,37 @@ console.log('In video_block_main.js file'); const bumperMetadata = el.data('bumper-metadata'); const autoAdvanceEnabled = el.data('autoadvance-enabled') === 'True'; - const mainVideoModules = [ - FocusGrabber, - // VideoControl, - VideoPlayPlaceholder, - VideoPlayPauseControl, - // VideoProgressSlider, - // VideoSpeedControl, - // VideoVolumeControl, - // VideoQualityControl, - // VideoFullScreen, - // VideoCaption, - VideoCommands, - // VideoContextMenu, - VideoSaveStatePlugin, - VideoEventsPlugin, - // VideoCompletionHandler, - // VideoTranscriptFeedback, - // ].concat(autoAdvanceEnabled ? [VideoAutoAdvanceControl] : []); - ] + const mainVideoModules = [] + // TODO: Uncomment the code + // const mainVideoModules = [ + // FocusGrabber, + // VideoControl, + // VideoPlayPlaceholder, + // VideoPlayPauseControl, + // VideoProgressSlider, + // VideoSpeedControl, + // VideoVolumeControl, + // VideoQualityControl, + // VideoFullScreen, + // VideoCaption, + // VideoCommands, + // VideoContextMenu, + // VideoSaveStatePlugin, + // VideoEventsPlugin, + // VideoCompletionHandler, + // VideoTranscriptFeedback, + // ].concat(autoAdvanceEnabled ? [VideoAutoAdvanceControl] : []); const bumperVideoModules = [ // VideoControl, - VideoPlaySkipControl, - VideoSkipControl, + // VideoPlaySkipControl, + // VideoSkipControl, // VideoVolumeControl, // VideoCaption, - VideoCommands, - VideoSaveStatePlugin, + // VideoCommands, + // VideoSaveStatePlugin, // VideoTranscriptFeedback, - VideoEventsBumperPlugin, + // VideoEventsBumperPlugin, // VideoCompletionHandler, ]; @@ -125,7 +125,7 @@ console.log('In video_block_main.js file'); saveStateUrl: state.metadata.saveStateUrl, }); - VideoSocialSharingHandler(el); + // VideoSocialSharing(el); if (bumperMetadata) { VideoPoster(el, { @@ -184,3 +184,4 @@ console.log('In video_block_main.js file'); // oldVideo(null, true); }()); + diff --git a/xmodule/assets/video/public/js/video_caption.js b/xmodule/assets/video/public/js/video_caption.js new file mode 100644 index 000000000000..99302cf7e891 --- /dev/null +++ b/xmodule/assets/video/public/js/video_caption.js @@ -0,0 +1,1420 @@ +'use strict'; + +// TODO: Update these TODOs +import Sjson from 'video/00_sjson.js'; +import AsyncProcess from 'video/00_async_process.js'; +import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils'; +import Draggabilly from 'draggabilly'; +import convert from './time'; +import _ from 'underscore'; + +/** + * @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} + */ +const 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', + 'toggleGoogleDisclaimer' + ); + + 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( + [ + '
', + '', + '', + '', + '
' + ].join('')), + { + langTitle: gettext('Open language menu'), + courseId: this.state.id + } + ); + + var subtitlesHtml = HtmlUtils.interpolateHtml( + HtmlUtils.HTML( + [ + '
', + '

', + '
    ', + '
    ' + ].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('
  1. '), + gettext('Transcript will be displayed when you start playing the video.'), + HtmlUtils.HTML('
  2. ') + ) + ); + } 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 = $('