From 325fb2b7a9ecafa55d4b4f3e53a62eeb9738f39e Mon Sep 17 00:00:00 2001 From: farhan Date: Wed, 16 Apr 2025 10:59:50 +0500 Subject: [PATCH 1/2] chore: Initial setup of JS files and webpack config --- webpack.common.config.js | 1 + .../assets/video/public/js/async_process.js | 43 + xmodule/assets/video/public/js/commands.js | 137 ++ xmodule/assets/video/public/js/component.js | 69 + .../video/public/js/events_bumper_plugin.js | 179 +++ .../assets/video/public/js/events_plugin.js | 200 +++ .../assets/video/public/js/focus_grabber.js | 132 ++ .../assets/video/public/js/html5_hls_video.js | 99 ++ xmodule/assets/video/public/js/html5_video.js | 290 ++++ xmodule/assets/video/public/js/i18n.js | 18 + xmodule/assets/video/public/js/initialize.js | 825 ++++++++++ xmodule/assets/video/public/js/iterator.js | 78 + .../video/public/js/play_pause_control.js | 95 ++ .../video/public/js/play_placeholder.js | 87 + .../video/public/js/play_skip_control.js | 90 ++ xmodule/assets/video/public/js/poster.js | 69 + xmodule/assets/video/public/js/resizer.js | 191 +++ .../video/public/js/save_state_plugin.js | 189 +++ xmodule/assets/video/public/js/sjson.js | 137 ++ .../assets/video/public/js/skip_control.js | 70 + xmodule/assets/video/public/js/time.js | 43 + .../video/public/js/video_accessible_menu.js | 56 + .../public/js/video_auto_advance_control.js | 148 ++ .../video/public/js/video_block_main.js | 188 +++ .../assets/video/public/js/video_bumper.js | 111 ++ .../assets/video/public/js/video_caption.js | 1420 +++++++++++++++++ .../public/js/video_completion_handler.js | 132 ++ .../video/public/js/video_context_menu.js | 699 ++++++++ .../assets/video/public/js/video_control.js | 167 ++ .../video/public/js/video_full_screen.js | 251 +++ .../assets/video/public/js/video_player.js | 912 +++++++++++ .../video/public/js/video_progress_slider.js | 236 +++ .../video/public/js/video_quality_control.js | 186 +++ .../video/public/js/video_social_sharing.js | 77 + .../video/public/js/video_speed_control.js | 417 +++++ .../assets/video/public/js/video_storage.js | 79 + .../public/js/video_transcript_feedback.js | 241 +++ .../video/public/js/video_volume_control.js | 554 +++++++ xmodule/video_block/video_block.py | 4 +- 39 files changed, 8918 insertions(+), 2 deletions(-) create mode 100644 xmodule/assets/video/public/js/async_process.js create mode 100644 xmodule/assets/video/public/js/commands.js create mode 100644 xmodule/assets/video/public/js/component.js create mode 100644 xmodule/assets/video/public/js/events_bumper_plugin.js create mode 100644 xmodule/assets/video/public/js/events_plugin.js create mode 100644 xmodule/assets/video/public/js/focus_grabber.js create mode 100644 xmodule/assets/video/public/js/html5_hls_video.js create mode 100644 xmodule/assets/video/public/js/html5_video.js create mode 100644 xmodule/assets/video/public/js/i18n.js create mode 100644 xmodule/assets/video/public/js/initialize.js create mode 100644 xmodule/assets/video/public/js/iterator.js create mode 100644 xmodule/assets/video/public/js/play_pause_control.js create mode 100644 xmodule/assets/video/public/js/play_placeholder.js create mode 100644 xmodule/assets/video/public/js/play_skip_control.js create mode 100644 xmodule/assets/video/public/js/poster.js create mode 100644 xmodule/assets/video/public/js/resizer.js create mode 100644 xmodule/assets/video/public/js/save_state_plugin.js create mode 100644 xmodule/assets/video/public/js/sjson.js create mode 100644 xmodule/assets/video/public/js/skip_control.js create mode 100644 xmodule/assets/video/public/js/time.js create mode 100644 xmodule/assets/video/public/js/video_accessible_menu.js create mode 100644 xmodule/assets/video/public/js/video_auto_advance_control.js create mode 100644 xmodule/assets/video/public/js/video_block_main.js create mode 100644 xmodule/assets/video/public/js/video_bumper.js create mode 100644 xmodule/assets/video/public/js/video_caption.js create mode 100644 xmodule/assets/video/public/js/video_completion_handler.js create mode 100644 xmodule/assets/video/public/js/video_context_menu.js create mode 100644 xmodule/assets/video/public/js/video_control.js create mode 100644 xmodule/assets/video/public/js/video_full_screen.js create mode 100644 xmodule/assets/video/public/js/video_player.js create mode 100644 xmodule/assets/video/public/js/video_progress_slider.js create mode 100644 xmodule/assets/video/public/js/video_quality_control.js create mode 100644 xmodule/assets/video/public/js/video_social_sharing.js create mode 100644 xmodule/assets/video/public/js/video_speed_control.js create mode 100644 xmodule/assets/video/public/js/video_storage.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/webpack.common.config.js b/webpack.common.config.js index ca2ae368493d..89082f218524 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -139,6 +139,7 @@ module.exports = Merge.smart({ ReactRenderer: './common/static/js/src/ReactRenderer.jsx', XModuleShim: './xmodule/js/src/xmodule.js', VerticalStudentView: './xmodule/assets/vertical/public/js/vertical_student_view.js', + VideoBlockMain: './xmodule/assets/video/public/js/video_block_main.js', commons: 'babel-polyfill' }, diff --git a/xmodule/assets/video/public/js/async_process.js b/xmodule/assets/video/public/js/async_process.js new file mode 100644 index 000000000000..a4d997e28e45 --- /dev/null +++ b/xmodule/assets/video/public/js/async_process.js @@ -0,0 +1,43 @@ +/** + * Provides a convenient way to process a large amount of data without UI blocking. + * + * @param {Array} list - The array to process. + * @param {Function} process - The function to execute on each item. + * @returns {Promise} - Resolves with the processed array. + */ +export const AsyncProcess = { + array(list, process) { + // Validate input + if (!Array.isArray(list)) { + return Promise.reject(new Error('Input is not an array')); + } + + if (typeof process !== 'function' || list.length === 0) { + return Promise.resolve(list); + } + + const MAX_DELAY = 50; + const result = []; + let index = 0; + const len = list.length; + + const handler = (resolve) => { + const start = Date.now(); + + while (index < len && Date.now() - start < MAX_DELAY) { + result[index] = process(list[index], index); + index++; + } + + if (index < len) { + setTimeout(() => handler(resolve), 25); + } else { + resolve(result); + } + }; + + return new Promise((resolve) => { + setTimeout(() => handler(resolve), 25); + }); + } +}; diff --git a/xmodule/assets/video/public/js/commands.js b/xmodule/assets/video/public/js/commands.js new file mode 100644 index 000000000000..0044973d958e --- /dev/null +++ b/xmodule/assets/video/public/js/commands.js @@ -0,0 +1,137 @@ +import $ from 'jquery'; +import _ from 'underscore'; + +'use strict'; + +/** + * Video commands module. + * + * @constructor + * @param {Object} state - The object containing the state of the video + * @param {Object} i18n - The object containing strings with translations + * @return {jQuery.Promise} - A resolved jQuery promise + */ +function VideoCommands(state, i18n) { + if (!(this instanceof VideoCommands)) { + return new VideoCommands(state, i18n); + } + + _.bindAll(this, 'destroy'); + this.state = state; + this.state.videoCommands = this; + this.i18n = i18n; + this.commands = []; + this.initialize(); + + return $.Deferred().resolve().promise(); +} + +VideoCommands.prototype = { + /** + * Initializes the module by loading commands and binding events. + */ + initialize: function () { + this.commands = this.getCommands(); + this.state.el.on('destroy', this.destroy); + }, + + /** + * Cleans up the module by removing event handlers and deleting the instance. + */ + destroy: function () { + this.state.el.off('destroy', this.destroy); + delete this.state.videoCommands; + }, + + /** + * Executes a given command with optional arguments. + * + * @param {String} command - The name of the command to execute + * @param {...*} args - Additional arguments to pass to the command + */ + execute: function (command, ...args) { + if (_.has(this.commands, command)) { + this.commands[command].execute(this.state, ...args); + } else { + console.log(`Command "${command}" is not available.`); + } + }, + + /** + * Returns the available commands as an object. + * + * @return {Object} - A dictionary of available commands + */ + getCommands: function () { + const commands = {}; + const commandsList = [ + playCommand, + pauseCommand, + togglePlaybackCommand, + toggleMuteCommand, + toggleFullScreenCommand, + setSpeedCommand, + skipCommand, + ]; + + _.each(commandsList, (command) => { + commands[command.name] = command; + }); + + return commands; + }, +}; + +/** + * Command constructor. + * + * @constructor + * @param {String} name - The name of the command + * @param {Function} execute - The function to execute the command + */ +function Command(name, execute) { + this.name = name; + this.execute = execute; +} + +// Individual command definitions +const playCommand = new Command('play', (state) => { + state.videoPlayer.play(); +}); + +const pauseCommand = new Command('pause', (state) => { + state.videoPlayer.pause(); +}); + +const togglePlaybackCommand = new Command('togglePlayback', (state) => { + if (state.videoPlayer.isPlaying()) { + pauseCommand.execute(state); + } else { + playCommand.execute(state); + } +}); + +const toggleMuteCommand = new Command('toggleMute', (state) => { + state.videoVolumeControl.toggleMute(); +}); + +const toggleFullScreenCommand = new Command('toggleFullScreen', (state) => { + state.videoFullScreen.toggle(); +}); + +const setSpeedCommand = new Command( + 'speed', + (state, speed) => { + state.videoSpeedControl.setSpeed(state.speedToString(speed)); + } +); + +const skipCommand = new Command('skip', (state, doNotShowAgain) => { + if (doNotShowAgain) { + state.videoBumper.skipAndDoNotShowAgain(); + } else { + state.videoBumper.skip(); + } +}); + +export {VideoCommands}; diff --git a/xmodule/assets/video/public/js/component.js b/xmodule/assets/video/public/js/component.js new file mode 100644 index 000000000000..6833eeafe388 --- /dev/null +++ b/xmodule/assets/video/public/js/component.js @@ -0,0 +1,69 @@ +'use strict'; + +/** + * Creates a new object with the specified prototype object and properties. + * @param {Object} o The object which should be the prototype of the newly-created object. + * @private + * @throws {TypeError, Error} + * @return {Object} + */ +const inherit = Object.create || (function () { + const F = function () { }; + + return function (o) { + if (arguments.length > 1) { + throw Error('Second argument not supported'); + } + if (_.isNull(o) || _.isUndefined(o)) { + throw Error('Cannot set a null [[Prototype]]'); + } + if (!_.isObject(o)) { + throw TypeError('Argument must be an object'); + } + + F.prototype = o; + return new F(); + }; +}()); + +/** + * Component constructor function. + * Calls `initialize()` if defined. + * @returns {any} + */ +function Component() { + if ($.isFunction(this.initialize)) { + return this.initialize.apply(this, arguments); + } +} + +/** + * Adds an `extend` method to the Component constructor. + * Creates a subclass that inherits from Component. + * @param {Object} protoProps - Prototype methods and properties. + * @param {Object} staticProps - Static methods and properties. + * @returns {Function} Child constructor. + */ +Component.extend = function (protoProps, staticProps) { + const Parent = this; + + const Child = function () { + if ($.isFunction(this.initialize)) { + return this.initialize.apply(this, arguments); + } + }; + + Child.prototype = inherit(Parent.prototype); + Child.constructor = Parent; + Child.__super__ = Parent.prototype; + + if (protoProps) { + $.extend(Child.prototype, protoProps); + } + + $.extend(Child, Parent, staticProps); + + return Child; +}; + +export { Component }; diff --git a/xmodule/assets/video/public/js/events_bumper_plugin.js b/xmodule/assets/video/public/js/events_bumper_plugin.js new file mode 100644 index 000000000000..0b93b947a294 --- /dev/null +++ b/xmodule/assets/video/public/js/events_bumper_plugin.js @@ -0,0 +1,179 @@ +import $ from 'jquery'; +import _ from 'underscore'; + +'use strict'; + +/** + * Events module. + * + * @constructor + * @param {Object} state - The object containing the state of the video + * @param {Object} i18n - The object containing strings with translations + * @param {Object} options - Additional options for the plugin + * @return {jQuery.Promise} - A resolved jQuery promise + */ +function VideoEventsBumperPlugin(state, i18n, options) { + if (!(this instanceof VideoEventsBumperPlugin)) { + return new VideoEventsBumperPlugin(state, i18n, options); + } + + _.bindAll( + this, + 'onReady', + 'onPlay', + 'onEnded', + 'onShowLanguageMenu', + 'onHideLanguageMenu', + 'onSkip', + 'onShowCaptions', + 'onHideCaptions', + 'destroy' + ); + + this.state = state; + this.options = _.extend({}, options); + this.state.videoEventsBumperPlugin = this; + this.i18n = i18n; + + this.initialize(); + + return $.Deferred().resolve().promise(); +} + +VideoEventsBumperPlugin.moduleName = 'EventsBumperPlugin'; + +VideoEventsBumperPlugin.prototype = { + /** + * Initialize the plugin by binding the required event handlers + */ + initialize: function () { + this.events = { + ready: this.onReady, + play: this.onPlay, + 'ended stop': this.onEnded, + skip: this.onSkip, + 'language_menu:show': this.onShowLanguageMenu, + 'language_menu:hide': this.onHideLanguageMenu, + 'captions:show': this.onShowCaptions, + 'captions:hide': this.onHideCaptions, + destroy: this.destroy, + }; + this.bindHandlers(); + }, + + /** + * Bind event handlers to the video state element + */ + bindHandlers: function () { + this.state.el.on(this.events); + }, + + /** + * Cleanup by removing event handlers and destroying the plugin instance + */ + destroy: function () { + this.state.el.off(this.events); + delete this.state.videoEventsBumperPlugin; + }, + + /** + * Handle the `ready` event + */ + onReady: function () { + this.log('edx.video.bumper.loaded'); + }, + + /** + * Handle the `play` event + */ + onPlay: function () { + this.log('edx.video.bumper.played', { currentTime: this.getCurrentTime() }); + }, + + /** + * Handle the `ended` and `stop` events + */ + onEnded: function () { + this.log('edx.video.bumper.stopped', { currentTime: this.getCurrentTime() }); + }, + + /** + * Handle the `skip` event + */ + onSkip: function (event, doNotShowAgain) { + const info = { currentTime: this.getCurrentTime() }; + const eventName = `edx.video.bumper.${doNotShowAgain ? 'dismissed' : 'skipped'}`; + this.log(eventName, info); + }, + + /** + * Handle when the language menu is shown + */ + onShowLanguageMenu: function () { + this.log('edx.video.bumper.transcript.menu.shown'); + }, + + /** + * Handle when the language menu is hidden + */ + onHideLanguageMenu: function () { + this.log('edx.video.bumper.transcript.menu.hidden'); + }, + + /** + * Handle when captions are shown + */ + onShowCaptions: function () { + this.log('edx.video.bumper.transcript.shown', { currentTime: this.getCurrentTime() }); + }, + + /** + * Handle when captions are hidden + */ + onHideCaptions: function () { + this.log('edx.video.bumper.transcript.hidden', { currentTime: this.getCurrentTime() }); + }, + + /** + * Get the current time of the video + * + * @return {Number} - The current time of the video in seconds + */ + getCurrentTime: function () { + const player = this.state.videoPlayer; + return player ? player.currentTime : 0; + }, + + /** + * Get the duration of the video + * + * @return {Number} - The duration of the video in seconds + */ + getDuration: function () { + const player = this.state.videoPlayer; + return player ? player.duration() : 0; + }, + + /** + * Log an event + * + * @param {String} eventName - The name of the event to log + * @param {Object} data - Additional data to log with the event + */ + log: function (eventName, data) { + const logInfo = _.extend( + { + host_component_id: this.state.id, + bumper_id: this.state.config.sources[0] || '', + duration: this.getDuration(), + code: 'html5', + }, + data, + this.options.data + ); + + Logger.log(eventName, logInfo); + }, +}; + +export { VideoEventsBumperPlugin }; \ No newline at end of file diff --git a/xmodule/assets/video/public/js/events_plugin.js b/xmodule/assets/video/public/js/events_plugin.js new file mode 100644 index 000000000000..9c315b9f4443 --- /dev/null +++ b/xmodule/assets/video/public/js/events_plugin.js @@ -0,0 +1,200 @@ +import $ from 'jquery'; +import _ from 'underscore'; + +'use strict'; + +/** + * Events module. + * + * @constructor + * @param {Object} state - The object containing the state of the video + * @param {Object} i18n - The object containing strings with translations + * @param {Object} options - Additional options for the plugin + * @return {jQuery.Promise} - A resolved jQuery promise + */ +function VideoEventsPlugin(state, i18n, options) { + if (!(this instanceof VideoEventsPlugin)) { + return new VideoEventsPlugin(state, i18n, options); + } + + _.bindAll( + this, + 'onReady', + 'onPlay', + 'onPause', + 'onComplete', + 'onEnded', + 'onSeek', + 'onSpeedChange', + 'onAutoAdvanceChange', + 'onShowLanguageMenu', + 'onHideLanguageMenu', + 'onSkip', + 'onShowTranscript', + 'onHideTranscript', + 'onShowCaptions', + 'onHideCaptions', + 'destroy' + ); + + this.state = state; + this.options = _.extend({}, options); + this.state.videoEventsPlugin = this; + this.i18n = i18n; + this.initialize(); + + return $.Deferred().resolve().promise(); +} + +VideoEventsPlugin.moduleName = 'EventsPlugin'; +VideoEventsPlugin.prototype = { + /** + * Initialize the EventsPlugin module. + */ + initialize: function () { + this.events = { + ready: this.onReady, + play: this.onPlay, + pause: this.onPause, + complete: this.onComplete, + 'ended stop': this.onEnded, + seek: this.onSeek, + skip: this.onSkip, + speedchange: this.onSpeedChange, + autoadvancechange: this.onAutoAdvanceChange, + 'language_menu:show': this.onShowLanguageMenu, + 'language_menu:hide': this.onHideLanguageMenu, + 'transcript:show': this.onShowTranscript, + 'transcript:hide': this.onHideTranscript, + 'captions:show': this.onShowCaptions, + 'captions:hide': this.onHideCaptions, + destroy: this.destroy, + }; + + this.bindHandlers(); + this.emitPlayVideoEvent = true; + }, + + /** + * Destroy the EventPlugin instance and cleanup. + */ + destroy: function () { + this.state.el.off(this.events); + delete this.state.videoEventsPlugin; + }, + + bindHandlers: function () { + this.state.el.on(this.events); + }, + + onReady: function () { + this.log('load_video'); + }, + + onPlay: function () { + if (this.emitPlayVideoEvent) { + this.log('play_video', {currentTime: this.getCurrentTime()}); + this.emitPlayVideoEvent = false; + } + }, + + onPause: function () { + this.log('pause_video', {currentTime: this.getCurrentTime()}); + this.emitPlayVideoEvent = true; + }, + + onComplete: function () { + this.log('complete_video', {currentTime: this.getCurrentTime()}); + }, + + onEnded: function () { + this.log('stop_video', {currentTime: this.getCurrentTime()}); + this.emitPlayVideoEvent = true; + }, + + onSkip: function (event, doNotShowAgain) { + var info = {currentTime: this.getCurrentTime()}, + eventName = doNotShowAgain ? 'do_not_show_again_video' : 'skip_video'; + this.log(eventName, info); + }, + + onSeek: function (event, time, oldTime, type) { + this.log('seek_video', { + old_time: oldTime, + new_time: time, + type: type + }); + this.emitPlayVideoEvent = true; + }, + + onSpeedChange: function (event, newSpeed, oldSpeed) { + this.log('speed_change_video', { + current_time: this.getCurrentTime(), + old_speed: this.state.speedToString(oldSpeed), + new_speed: this.state.speedToString(newSpeed) + }); + }, + + onAutoAdvanceChange: function (event, enabled) { + this.log('auto_advance_change_video', { + enabled: enabled + }); + }, + + onShowLanguageMenu: function () { + this.log('edx.video.language_menu.shown'); + }, + + onHideLanguageMenu: function () { + this.log('edx.video.language_menu.hidden', {language: this.getCurrentLanguage()}); + }, + + onShowTranscript: function () { + this.log('show_transcript', {current_time: this.getCurrentTime()}); + }, + + onHideTranscript: function () { + this.log('hide_transcript', {current_time: this.getCurrentTime()}); + }, + + onShowCaptions: function () { + this.log('edx.video.closed_captions.shown', {current_time: this.getCurrentTime()}); + }, + + onHideCaptions: function () { + this.log('edx.video.closed_captions.hidden', {current_time: this.getCurrentTime()}); + }, + + getCurrentTime: function () { + var player = this.state.videoPlayer, + startTime = this.state.config.startTime, + currentTime; + currentTime = player ? player.currentTime : 0; + // if video didn't start from 0(it's a subsection of video), subtract the additional time at start + if (startTime) { + currentTime = currentTime ? currentTime - startTime : 0; + } + return currentTime; + }, + + getCurrentLanguage: function () { + var language = this.state.lang; + return language; + }, + + log: function (eventName, data) { + // use startTime and endTime to calculate the duration to handle the case where only a subsection of video is used + var endTime = this.state.config.endTime || this.state.duration, + startTime = this.state.config.startTime; + + var logInfo = _.extend({ + id: this.state.id, + // eslint-disable-next-line no-nested-ternary + code: this.state.isYoutubeType() ? this.state.youtubeId() : this.state.canPlayHLS ? 'hls' : 'html5', + duration: endTime - startTime + }, data, this.options.data); + Logger.log(eventName, logInfo); + } +}; + +export {VideoEventsPlugin}; diff --git a/xmodule/assets/video/public/js/focus_grabber.js b/xmodule/assets/video/public/js/focus_grabber.js new file mode 100644 index 000000000000..66b7ca713d99 --- /dev/null +++ b/xmodule/assets/video/public/js/focus_grabber.js @@ -0,0 +1,132 @@ +/* + * 025_focus_grabber.js + * + * Purpose: Provide a way to focus on autohidden Video controls. + * + * + * Because in HTML player mode we have a feature of autohiding controls on + * mouse inactivity, sometimes focus is lost from the currently selected + * control. What's more, when all controls are autohidden, we can't get to any + * of them because by default browser does not place hidden elements on the + * focus chain. + * + * To get around this minor annoyance, this module will manage 2 placeholder + * elements that will be invisible to the user's eye, but visible to the + * browser. This will allow for a sneaky stealing of focus and placing it where + * we need (on hidden controls). + * + * This code has been moved to a separate module because it provides a concrete + * block of functionality that can be turned on (off). + */ + +/* + * "If you want to climb a mountain, begin at the top." + * + * ~ Zen saying + */ + +import $ from 'jquery'; + +// FocusGrabber module. +const FocusGrabber = function (state) { + var dfd = $.Deferred(); + + state.focusGrabber = {}; + + _makeFunctionsPublic(state); + _renderElements(state); + _bindHandlers(state); + + dfd.resolve(); + return dfd.promise(); +}; + +// Private functions. + +function _makeFunctionsPublic(state) { + var methodsDict = { + disableFocusGrabber: disableFocusGrabber, + enableFocusGrabber: enableFocusGrabber, + onFocus: onFocus + }; + + state.bindTo(methodsDict, state.focusGrabber, state); +} + +function _renderElements(state) { + state.focusGrabber.elFirst = state.el.find('.focus_grabber.first'); + state.focusGrabber.elLast = state.el.find('.focus_grabber.last'); + + // From the start, the Focus Grabber must be disabled so that + // tabbing (switching focus) does not land the user on one of the + // placeholder elements (elFirst, elLast). + state.focusGrabber.disableFocusGrabber(); +} + +function _bindHandlers(state) { + state.focusGrabber.elFirst.on('focus', state.focusGrabber.onFocus); + state.focusGrabber.elLast.on('focus', state.focusGrabber.onFocus); + + // When the video container element receives programmatic focus, then + // on un-focus ('blur' event) we should trigger a 'mousemove' event so + // as to reveal autohidden controls. + state.el.on('blur', function () { + state.el.trigger('mousemove'); + }); +} + +// Public functions. + +function enableFocusGrabber() { + var tabIndex; + + // When the Focus Grabber is being enabled, there are two different + // scenarios: + // + // 1.) Currently focused element was inside the video player. + // 2.) Currently focused element was somewhere else on the page. + // + // In the first case we must make sure that the video player doesn't + // loose focus, even though the controls are autohidden. + if ($(document.activeElement).parents().hasClass('video')) { + tabIndex = -1; + } else { + tabIndex = 0; + } + + this.focusGrabber.elFirst.attr('tabindex', tabIndex); + this.focusGrabber.elLast.attr('tabindex', tabIndex); + + // Don't loose focus. We are inside video player on some control, but + // because we can't remain focused on a hidden element, we will shift + // focus to the main video element. + // + // Once the main element will receive the un-focus ('blur') event, a + // 'mousemove' event will be triggered, and the video controls will + // receive focus once again. + if (tabIndex === -1) { + this.el.focus(); + + this.focusGrabber.elFirst.attr('tabindex', 0); + this.focusGrabber.elLast.attr('tabindex', 0); + } +} + +function disableFocusGrabber() { + // Only programmatic focusing on these elements will be available. + // We don't want the user to focus on them (for example with the 'Tab' + // key). + this.focusGrabber.elFirst.attr('tabindex', -1); + this.focusGrabber.elLast.attr('tabindex', -1); +} + +function onFocus(event, params) { + // Once the Focus Grabber placeholder elements will gain focus, we will + // trigger 'mousemove' event so that the autohidden controls will + // become visible. + this.el.trigger('mousemove'); + + this.focusGrabber.disableFocusGrabber(); +} + +export {FocusGrabber}; diff --git a/xmodule/assets/video/public/js/html5_hls_video.js b/xmodule/assets/video/public/js/html5_hls_video.js new file mode 100644 index 000000000000..fad512248bcb --- /dev/null +++ b/xmodule/assets/video/public/js/html5_hls_video.js @@ -0,0 +1,99 @@ +import { bindAll, once } from 'underscore'; +import { Player as HTML5Player } from './02_html5_video.js'; +import Hls from 'hls.js'; + +export class HLSVideoPlayer extends HTML5Player { + constructor(el, config) { + super(); + + this.config = config; + this.init(el, config); + + bindAll(this, 'playVideo', 'pauseVideo', 'onReady'); + + // Handle unsupported HLS + if (config.HLSOnlySources && !config.canPlayHLS) { + this.showErrorMessage(null, '.video-hls-error'); + return; + } + + // Setup on initialize + const onInitialize = once(() => { + console.log('[HLS Video]: HLS Player initialized'); + this.showPlayButton(); + }); + config.state.el.addEventListener('initialize', onInitialize); + + // Handle native Safari HLS + if (config.browserIsSafari) { + this.videoEl.setAttribute('src', config.videoSources[0]); + } else { + this.hls = new Hls({ + autoStartLoad: config.state.auto_advance ?? false + }); + + this.hls.loadSource(config.videoSources[0]); + this.hls.attachMedia(this.video); + + this.hls.on(Hls.Events.ERROR, this.onError.bind(this)); + + this.hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => { + console.log('[HLS Video]: MANIFEST_PARSED, qualityLevelsInfo:', + data.levels.map(level => ({ + bitrate: level.bitrate, + resolution: `${level.width}x${level.height}` + })) + ); + this.config.onReadyHLS?.(); + }); + + this.hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => { + const level = this.hls.levels[data.level]; + console.log('[HLS Video]: LEVEL_SWITCHED, qualityLevelInfo:', { + bitrate: level.bitrate, + resolution: `${level.width}x${level.height}` + }); + }); + } + } + + playVideo() { + super.updatePlayerLoadingState('show'); + if (!this.config.browserIsSafari) { + this.hls.startLoad(); + } + super.playVideo(); + } + + pauseVideo() { + super.pauseVideo(); + super.updatePlayerLoadingState('hide'); + } + + onPlaying() { + super.onPlaying(); + super.updatePlayerLoadingState('hide'); + } + + onReady() { + this.config.events.onReady?.(null); + } + + onError(event, data) { + if (!data.fatal) return; + + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + console.error('[HLS Video]: Fatal network error. Details:', data.details); + this.hls.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + console.error('[HLS Video]: Fatal media error. Details:', data.details); + this.hls.recoverMediaError(); + break; + default: + console.error('[HLS Video]: Unrecoverable error. Details:', data.details); + break; + } + } +} diff --git a/xmodule/assets/video/public/js/html5_video.js b/xmodule/assets/video/public/js/html5_video.js new file mode 100644 index 000000000000..75f16181305d --- /dev/null +++ b/xmodule/assets/video/public/js/html5_video.js @@ -0,0 +1,290 @@ +/* eslint-disable no-console, no-param-reassign */ +'use strict'; + +import _ from 'underscore'; + +// HTML5Video object that will be exported +const HTML5Video = {}; + +// Constants mimicking the YouTube API +HTML5Video.PlayerState = { + UNSTARTED: -1, + ENDED: 0, + PLAYING: 1, + PAUSED: 2, + BUFFERING: 3, + CUED: 5 +}; + +// HTML5Video.Player constructor function +class Player { + constructor(el, config) { + this.init(el, config); + + const sourceList = $.map(config.videoSources, source => { + const separator = source.indexOf('?') === -1 ? '?' : '&'; + return ``; + }); + + const errorMessage = [ + gettext('This browser cannot play .mp4, .ogg, or .webm files.'), + gettext('Try using a different browser, such as Google Chrome.') + ].join(''); + + this.video.innerHTML = sourceList.join('') + errorMessage; + + const lastSource = this.videoEl.find('source').last(); + lastSource.on('error', this.showErrorMessage.bind(this)); + lastSource.on('error', this.onError.bind(this)); + this.videoEl.on('error', this.onError.bind(this)); + } + + init(el, config) { + const self = this; + const isTouch = window.onTouchBasedDevice?.() || ''; + const events = [ + 'loadstart', 'progress', 'suspend', 'abort', 'error', + 'emptied', 'stalled', 'play', 'pause', 'loadedmetadata', + 'loadeddata', 'waiting', 'playing', 'canplay', 'canplaythrough', + 'seeking', 'seeked', 'timeupdate', 'ended', 'ratechange', + 'durationchange', 'volumechange' + ]; + + this.config = config; + this.logs = []; + this.el = $(el); + + this.video = document.createElement('video'); + this.videoEl = $(this.video); + this.videoOverlayEl = this.el.find('.video-wrapper .btn-play'); + this.playerState = HTML5Video.PlayerState.UNSTARTED; + + _.bindAll(this, 'onLoadedMetadata', 'onPlay', 'onPlaying', 'onPause', 'onEnded'); + + const togglePlayback = () => { + const { PLAYING, PAUSED } = HTML5Video.PlayerState; + if (self.playerState === PLAYING) { + self.playerState = PAUSED; + self.pauseVideo(); + } else { + self.playerState = PLAYING; + self.playVideo(); + } + }; + + this.videoEl.on('click', togglePlayback); + this.videoOverlayEl.on('click', togglePlayback); + + this.debug = false; + + $.each(events, (index, eventName) => { + self.video.addEventListener(eventName, function (...args) { + self.logs.push({ + 'event name': eventName, + state: self.playerState + }); + + if (self.debug) { + console.log( + 'event name:', eventName, + 'state:', self.playerState, + 'readyState:', self.video.readyState, + 'networkState:', self.video.networkState + ); + } + + self.el.trigger(`html5:${eventName}`, args); + }); + }); + + this.video.addEventListener('loadedmetadata', this.onLoadedMetadata, false); + this.video.addEventListener('play', this.onPlay, false); + this.video.addEventListener('playing', this.onPlaying, false); + this.video.addEventListener('pause', this.onPause, false); + this.video.addEventListener('ended', this.onEnded, false); + + if (/iP(hone|od)/i.test(isTouch[0])) { + this.videoEl.prop('controls', true); + } + + if (config.poster) { + this.videoEl.prop('poster', config.poster); + } + + this.videoEl.appendTo(this.el.find('.video-player > div:first-child')); + } + + showPlayButton() { + this.videoOverlayEl.removeClass('is-hidden'); + } + + hidePlayButton() { + this.videoOverlayEl.addClass('is-hidden'); + } + + showLoading() { + this.el + .removeClass('is-initialized') + .find('.spinner') + .removeAttr('tabindex') + .attr({ 'aria-hidden': 'false' }); + } + + hideLoading() { + this.el + .addClass('is-initialized') + .find('.spinner') + .attr({ 'aria-hidden': 'false', tabindex: -1 }); + } + + updatePlayerLoadingState(state) { + if (state === 'show') { + this.hidePlayButton(); + this.showLoading(); + } else if (state === 'hide') { + this.hideLoading(); + } + } + + callStateChangeCallback() { + const callback = this.config.events.onStateChange; + if ($.isFunction(callback)) { + callback({ data: this.playerState }); + } + } + + pauseVideo() { + this.video.pause(); + } + + seekTo(value) { + if (typeof value === 'number' && value <= this.video.duration && value >= 0) { + this.video.currentTime = value; + } + } + + setVolume(value) { + if (typeof value === 'number' && value <= 100 && value >= 0) { + this.video.volume = value * 0.01; + } + } + + getCurrentTime() { + return this.video.currentTime; + } + + playVideo() { + this.video.play(); + } + + getPlayerState() { + return this.playerState; + } + + getVolume() { + return this.video.volume; + } + + getDuration() { + return isNaN(this.video.duration) ? 0 : this.video.duration; + } + + setPlaybackRate(value) { + const newSpeed = parseFloat(value); + if (isFinite(newSpeed) && this.video.playbackRate !== value) { + this.video.playbackRate = value; + } + } + + getAvailablePlaybackRates() { + return [0.75, 1.0, 1.25, 1.5, 2.0]; + } + + _getLogs() { + return this.logs; + } + + showErrorMessage(_, cssSelector = '.video-player .video-error') { + this.el + .find('.video-player div') + .addClass('hidden') + .end() + .find(cssSelector) + .removeClass('is-hidden') + .end() + .addClass('is-initialized') + .find('.spinner') + .attr({ 'aria-hidden': 'true', tabindex: -1 }); + } + + onError() { + const callback = this.config.events.onError; + if ($.isFunction(callback)) { + callback(); + } + } + + destroy() { + this.video.removeEventListener('loadedmetadata', this.onLoadedMetadata, false); + this.video.removeEventListener('play', this.onPlay, false); + this.video.removeEventListener('playing', this.onPlaying, false); + this.video.removeEventListener('pause', this.onPause, false); + this.video.removeEventListener('ended', this.onEnded, false); + + this.el + .find('.video-player div') + .removeClass('is-hidden') + .end() + .find('.video-player .video-error') + .addClass('is-hidden') + .end() + .removeClass('is-initialized') + .find('.spinner') + .attr({ 'aria-hidden': 'false' }); + + this.videoEl.off('remove'); + this.videoEl.remove(); + } + + onReady() { + if ($.isFunction(this.config.events.onReady)) { + this.config.events.onReady(null); + } + this.showPlayButton(); + } + + onLoadedMetadata() { + this.playerState = HTML5Video.PlayerState.PAUSED; + if ($.isFunction(this.config.events.onReady)) { + this.onReady(); + } + } + + onPlay() { + this.playerState = HTML5Video.PlayerState.BUFFERING; + this.callStateChangeCallback(); + this.videoOverlayEl.addClass('is-hidden'); + } + + onPlaying() { + this.playerState = HTML5Video.PlayerState.PLAYING; + this.callStateChangeCallback(); + this.videoOverlayEl.addClass('is-hidden'); + } + + onPause() { + this.playerState = HTML5Video.PlayerState.PAUSED; + this.callStateChangeCallback(); + this.showPlayButton(); + } + + onEnded() { + this.playerState = HTML5Video.PlayerState.ENDED; + this.callStateChangeCallback(); + } +} + +// Attach to exported object +HTML5Video.Player = Player; + +export { HTML5Video }; \ No newline at end of file diff --git a/xmodule/assets/video/public/js/i18n.js b/xmodule/assets/video/public/js/i18n.js new file mode 100644 index 000000000000..24186a06a837 --- /dev/null +++ b/xmodule/assets/video/public/js/i18n.js @@ -0,0 +1,18 @@ +export const i18n = { + Play: gettext('Play'), + Pause: gettext('Pause'), + Mute: gettext('Mute'), + Unmute: gettext('Unmute'), + 'Exit full browser': gettext('Exit full browser'), + 'Fill browser': gettext('Fill browser'), + Speed: gettext('Speed'), + 'Auto-advance': gettext('Auto-advance'), + Volume: gettext('Volume'), + Muted: gettext('Muted'), // Volume level = 0% + 'Very low': gettext('Very low'), // 0-20% + Low: gettext('Low'), // 20-40% + Average: gettext('Average'), // 40-60% + Loud: gettext('Loud'), // 60-80% + 'Very loud': gettext('Very loud'), // 80-99% + Maximum: gettext('Maximum') // 100% +}; diff --git a/xmodule/assets/video/public/js/initialize.js b/xmodule/assets/video/public/js/initialize.js new file mode 100644 index 000000000000..879c76eb5030 --- /dev/null +++ b/xmodule/assets/video/public/js/initialize.js @@ -0,0 +1,825 @@ + +console.log("Within Initialize.js!"); +/** + * @function + * + * Initialize module exports this function. + * + * @param {object} state The object containg the state of the video player. + * All other modules, their parameters, public letiables, etc. are + * available via this object. + * @param {DOM element} element Container of the entire Video DOM element. + */ +let Initialize = function (state, element) { + _makeFunctionsPublic(state); + + state.initialize(element) + .done(function () { + if (state.isYoutubeType()) { + state.parseSpeed(); + } + // On iPhones and iPods native controls are used. + if (/iP(hone|od)/i.test(state.isTouch[0])) { + _hideWaitPlaceholder(state); + state.el.trigger('initialize', arguments); + + return false; + } + + _initializeModules(state, i18n) + .done(function () { + // On iPad ready state occurs just after start playing. + // We hide controls before video starts playing. + if (/iPad|Android/i.test(state.isTouch[0])) { + state.el.on('play', _.once(function () { + state.trigger('videoControl.show', null); + })); + } else { + // On PC show controls immediately. + state.trigger('videoControl.show', null); + } + + _hideWaitPlaceholder(state); + state.el.trigger('initialize', arguments); + }); + }); +}, + + /* eslint-disable no-use-before-define */ + methodsDict = { + bindTo, + fetchMetadata, + getCurrentLanguage, + getDuration, + getPlayerMode, + getVideoMetadata, + initialize, + isHtml5Mode, + isFlashMode, + isYoutubeType, + parseSpeed, + parseYoutubeStreams, + setPlayerMode, + setSpeed, + setAutoAdvance, + speedToString, + trigger, + youtubeId, + loadHtmlPlayer, + loadYoutubePlayer, + loadYouTubeIFrameAPI + }, + /* eslint-enable no-use-before-define */ + + _youtubeApiDeferred = null, + _oldOnYouTubeIframeAPIReady; + +Initialize.prototype = methodsDict; + +// *************************************************************** +// Private functions start here. Private functions start with underscore. +// *************************************************************** + +/** +* @function _makeFunctionsPublic +* +* Functions which will be accessible via 'state' object. When called, +* these functions will get the 'state' +* object as a context. +* +* @param {object} state The object containg the state (properties, +* methods, modules) of the Video player. +*/ +function _makeFunctionsPublic(state) { + bindTo(methodsDict, state, state); +} + +// function _renderElements(state) +// +// Create any necessary DOM elements, attach them, and set their +// initial configuration. Also make the created DOM elements available +// via the 'state' object. Much easier to work this way - you don't +// have to do repeated jQuery element selects. +function _renderElements(state) { + // Launch embedding of actual video content, or set it up so that it + // will be done as soon as the appropriate video player (YouTube or + // stand-alone HTML5) is loaded, and can handle embedding. + // + // Note that the loading of stand alone HTML5 player API is handled by + // Require JS. At the time when we reach this code, the stand alone + // HTML5 player is already loaded, so no further testing in that case + // is required. + let video, onYTApiReady, setupOnYouTubeIframeAPIReady; + + if (state.videoType === 'youtube') { + state.youtubeApiAvailable = false; + + onYTApiReady = function () { + console.log('[Video info]: YouTube API is available and is loaded.'); + if (state.htmlPlayerLoaded) { return; } + + console.log('[Video info]: Starting YouTube player.'); + video = VideoPlayer(state); + + state.modules.push(video); + state.__dfd__.resolve(); + state.youtubeApiAvailable = true; + }; + + if (window.YT) { + // If we have a Deferred object responsible for calling OnYouTubeIframeAPIReady + // callbacks, make sure that they have all been called by trying to resolve the + // Deferred object. Upon resolving, all the OnYouTubeIframeAPIReady will be + // called. If the object has been already resolved, the callbacks will not + // be called a second time. + if (_youtubeApiDeferred) { + _youtubeApiDeferred.resolve(); + } + + window.YT.ready(onYTApiReady); + } else { + // There is only one global letiable window.onYouTubeIframeAPIReady which + // is supposed to be a function that will be called by the YouTube API + // when it finished initializing. This function will update this global function + // so that it resolves our Deferred object, which will call all of the + // OnYouTubeIframeAPIReady callbacks. + // + // If this global function is already defined, we store it first, and make + // sure that it gets executed when our Deferred object is resolved. + setupOnYouTubeIframeAPIReady = function () { + _oldOnYouTubeIframeAPIReady = window.onYouTubeIframeAPIReady || undefined; + + window.onYouTubeIframeAPIReady = function () { + _youtubeApiDeferred.resolve(); + }; + + window.onYouTubeIframeAPIReady.done = _youtubeApiDeferred.done; + + if (_oldOnYouTubeIframeAPIReady) { + window.onYouTubeIframeAPIReady.done(_oldOnYouTubeIframeAPIReady); + } + }; + + // If a Deferred object hasn't been created yet, create one now. It will + // be responsible for calling OnYouTubeIframeAPIReady callbacks once the + // YouTube API loads. After creating the Deferred object, load the YouTube + // API. + if (!_youtubeApiDeferred) { + _youtubeApiDeferred = $.Deferred(); + setupOnYouTubeIframeAPIReady(); + } else if (!window.onYouTubeIframeAPIReady || !window.onYouTubeIframeAPIReady.done) { + // The Deferred object could have been already defined in a previous + // initialization of the video module. However, since then the global letiable + // window.onYouTubeIframeAPIReady could have been overwritten. If so, + // we should set it up again. + setupOnYouTubeIframeAPIReady(); + } + + // Attach a callback to our Deferred object to be called once the + // YouTube API loads. + window.onYouTubeIframeAPIReady.done(function () { + window.YT.ready(onYTApiReady); + }); + } + } else { + video = VideoPlayer(state); + + state.modules.push(video); + state.__dfd__.resolve(); + state.htmlPlayerLoaded = true; + } +} + +function _waitForYoutubeApi(state) { + console.log('[Video info]: Starting to wait for YouTube API to load.'); + window.setTimeout(function () { + // If YouTube API will load OK, it will run `onYouTubeIframeAPIReady` + // callback, which will set `state.youtubeApiAvailable` to `true`. + // If something goes wrong at this stage, `state.youtubeApiAvailable` is + // `false`. + if (!state.youtubeApiAvailable) { + console.log('[Video info]: YouTube API is not available.'); + if (!state.htmlPlayerLoaded) { + state.loadHtmlPlayer(); + } + } + state.el.trigger('youtube_availability', [state.youtubeApiAvailable]); + }, state.config.ytTestTimeout); +} + +function loadYouTubeIFrameAPI(scriptTag) { + let firstScriptTag = document.getElementsByTagName('script')[0]; + firstScriptTag.parentNode.insertBefore(scriptTag, firstScriptTag); +} + +// function _parseYouTubeIDs(state) +// The function parse YouTube stream ID's. +// @return +// false: We don't have YouTube video IDs to work with; most likely +// we have HTML5 video sources. +// true: Parsing of YouTube video IDs went OK, and we can proceed +// onwards to play YouTube videos. +function _parseYouTubeIDs(state) { + if (state.parseYoutubeStreams(state.config.streams)) { + state.videoType = 'youtube'; + + return true; + } + + console.log( + '[Video info]: Youtube Video IDs are incorrect or absent.' + ); + + return false; +} + +/** +* Extract HLS video URLs from available video URLs. +* +* @param {object} state The object contaning the state (properties, methods, modules) of the Video player. +* @returns Array of available HLS video source urls. +*/ +function extractHLSVideoSources(state) { + return _.filter(state.config.sources, function (source) { + return /\.m3u8(\?.*)?$/.test(source); + }); +} + +// function _prepareHTML5Video(state) +// The function prepare HTML5 video, parse HTML5 +// video sources etc. +function _prepareHTML5Video(state) { + state.speeds = ['0.75', '1.0', '1.25', '1.50', '2.0']; + // If none of the supported video formats can be played and there is no + // short-hand video links, than hide the spinner and show error message. + if (!state.config.sources.length) { + _hideWaitPlaceholder(state); + state.el + .find('.video-player div') + .addClass('hidden'); + state.el + .find('.video-player .video-error') + .removeClass('is-hidden'); + + return false; + } + + state.videoType = 'html5'; + + if (!_.keys(state.config.transcriptLanguages).length) { + state.config.showCaptions = false; + } + state.setSpeed(state.speed); + + return true; +} + +function _hideWaitPlaceholder(state) { + state.el + .addClass('is-initialized') + .find('.spinner') + .attr({ + 'aria-hidden': 'true', + tabindex: -1 + }); +} + +function _setConfigurations(state) { + state.setPlayerMode(state.config.mode); + // Possible value are: 'visible', 'hiding', and 'invisible'. + state.controlState = 'visible'; + state.controlHideTimeout = null; + state.captionState = 'invisible'; + state.captionHideTimeout = null; + state.HLSVideoSources = extractHLSVideoSources(state); +} + +// eslint-disable-next-line no-shadow +function _initializeModules(state, i18n) { + let dfd = $.Deferred(), + modulesList = $.map(state.modules, function (module) { + let options = state.options[module.moduleName] || {}; + if (_.isFunction(module)) { + return module(state, i18n, options); + } else if ($.isPlainObject(module)) { + return module; + } + }); + + $.when.apply(null, modulesList) + .done(dfd.resolve); + + return dfd.promise(); +} + +function _getConfiguration(data, storage) { + let isBoolean = function (value) { + let regExp = /^true$/i; + return regExp.test(value.toString()); + }, + // List of keys that will be extracted form the configuration. + extractKeys = [], + // Compatibility keys used to change names of some parameters in + // the final configuration. + compatKeys = { + start: 'startTime', + end: 'endTime' + }, + // Conversions used to pre-process some configuration data. + conversions = { + showCaptions: isBoolean, + autoplay: isBoolean, + autohideHtml5: isBoolean, + autoAdvance: function (value) { + let shouldAutoAdvance = storage.getItem('auto_advance'); + if (_.isUndefined(shouldAutoAdvance)) { + return isBoolean(value) || false; + } else { + return shouldAutoAdvance; + } + }, + savedVideoPosition: function (value) { + return storage.getItem('savedVideoPosition', true) + || Number(value) + || 0; + }, + speed: function (value) { + return storage.getItem('speed', true) || value; + }, + generalSpeed: function (value) { + return storage.getItem('general_speed') + || value + || '1.0'; + }, + transcriptLanguage: function (value) { + return storage.getItem('language') + || value + || 'en'; + }, + ytTestTimeout: function (value) { + value = parseInt(value, 10); + + if (!isFinite(value)) { + value = 1500; + } + + return value; + }, + startTime: function (value) { + value = parseInt(value, 10); + if (!isFinite(value) || value < 0) { + return 0; + } + + return value; + }, + endTime: function (value) { + value = parseInt(value, 10); + + if (!isFinite(value) || value === 0) { + return null; + } + + return value; + } + }, + config = {}; + + data = _.extend({ + startTime: 0, + endTime: null, + sub: '', + streams: '' + }, data); + + $.each(data, function (option, value) { + // Extract option that is in `extractKeys`. + if ($.inArray(option, extractKeys) !== -1) { + return; + } + + // Change option name to key that is in `compatKeys`. + if (compatKeys[option]) { + option = compatKeys[option]; + } + + // Pre-process data. + if (conversions[option]) { + if (_.isFunction(conversions[option])) { + value = conversions[option].call(this, value); + } else { + throw new TypeError(option + ' is not a function.'); + } + } + config[option] = value; + }); + + return config; +} + +// *************************************************************** +// Public functions start here. +// These are available via the 'state' object. Their context ('this' +// keyword) is the 'state' object. The magic private function that makes +// them available and sets up their context is makeFunctionsPublic(). +// *************************************************************** + +// function bindTo(methodsDict, obj, context, rewrite) +// Creates a new function with specific context and assigns it to the provided +// object. +// eslint-disable-next-line no-shadow +function bindTo(methodsDict, obj, context, rewrite) { + $.each(methodsDict, function (name, method) { + if (_.isFunction(method)) { + if (_.isUndefined(rewrite)) { + rewrite = true; + } + + if (_.isUndefined(obj[name]) || rewrite) { + obj[name] = _.bind(method, context); + } + } + }); +} + +function loadYoutubePlayer() { + if (this.htmlPlayerLoaded) { return; } + + console.log( + '[Video info]: Fetch metadata for YouTube video.' + ); + + this.fetchMetadata(); + this.parseSpeed(); +} + +function loadHtmlPlayer() { + // When the youtube link doesn't work for any reason + // (for example, firewall) any + // alternate sources should automatically play. + if (!_prepareHTML5Video(this)) { + console.log( + '[Video info]: Continue loading ' + + 'YouTube video.' + ); + + // Non-YouTube sources were not found either. + + this.el.find('.video-player div') + .removeClass('hidden'); + this.el.find('.video-player .video-error') + .addClass('is-hidden'); + + // If in reality the timeout was to short, try to + // continue loading the YouTube video anyways. + this.loadYoutubePlayer(); + } else { + console.log( + '[Video info]: Start HTML5 player.' + ); + + // In-browser HTML5 player does not support quality + // control. + this.el.find('.quality_control').hide(); + _renderElements(this); + } +} + +// function initialize(element) +// The function set initial configuration and preparation. + +function initialize(element) { + let self = this, + el = this.el, + id = this.id, + container = el.find('.video-wrapper'), + __dfd__ = $.Deferred(), + isTouch = onTouchBasedDevice() || ''; + + if (isTouch) { + el.addClass('is-touch'); + } + + $.extend(this, { + __dfd__: __dfd__, + container: container, + isFullScreen: false, + isTouch: isTouch + }); + + console.log('[Video info]: Initializing video with id "%s".', id); + + // We store all settings passed to us by the server in one place. These + // are "read only", so don't modify them. All letiable content lives in + // 'state' object. + // jQuery .data() return object with keys in lower camelCase format. + this.config = $.extend({}, _getConfiguration(this.metadata, this.storage), { + element: element, + fadeOutTimeout: 1400, + captionsFreezeTime: 10000, + mode: $.cookie('edX_video_player_mode'), + // Available HD qualities will only be accessible once the video has + // been played once, via player.getAvailableQualityLevels. + availableHDQualities: [] + }); + + if (this.config.endTime < this.config.startTime) { + this.config.endTime = null; + } + + this.lang = this.config.transcriptLanguage; + this.speed = this.speedToString( + this.config.speed || this.config.generalSpeed + ); + this.auto_advance = this.config.autoAdvance; + this.htmlPlayerLoaded = false; + this.duration = this.metadata.duration; + + _setConfigurations(this); + + // If `prioritizeHls` is set to true than `hls` is the primary playback + if (this.config.prioritizeHls || !(_parseYouTubeIDs(this))) { + // If we do not have YouTube ID's, try parsing HTML5 video sources. + if (!_prepareHTML5Video(this)) { + __dfd__.reject(); + // Non-YouTube sources were not found either. + return __dfd__.promise(); + } + + console.log('[Video info]: Start player in HTML5 mode.'); + _renderElements(this); + } else { + _renderElements(this); + + _waitForYoutubeApi(this); + + let scriptTag = document.createElement('script'); + + scriptTag.src = this.config.ytApiUrl; + scriptTag.async = true; + + $(scriptTag).on('load', function () { + self.loadYoutubePlayer(); + }); + $(scriptTag).on('error', function () { + console.log( + '[Video info]: YouTube returned an error for ' + + 'video with id "' + self.id + '".' + ); + // If the video is already loaded in `_waitForYoutubeApi` by the + // time we get here, then we shouldn't load it again. + if (!self.htmlPlayerLoaded) { + self.loadHtmlPlayer(); + } + }); + + window.Video.loadYouTubeIFrameAPI(scriptTag); + } + return __dfd__.promise(); +} + +// function parseYoutubeStreams(state, youtubeStreams) +// +// Take a string in the form: +// "iCawTYPtehk:0.75,KgpclqP-LBA:1.0,9-2670d5nvU:1.5" +// parse it, and make it available via the 'state' object. If we are +// not given a string, or it's length is zero, then we return false. +// +// @return +// false: We don't have YouTube video IDs to work with; most likely +// we have HTML5 video sources. +// true: Parsing of YouTube video IDs went OK, and we can proceed +// onwards to play YouTube videos. +function parseYoutubeStreams(youtubeStreams) { + if (_.isUndefined(youtubeStreams) || !youtubeStreams.length) { + return false; + } + + this.videos = {}; + + _.each(youtubeStreams.split(/,/), function (video) { + let speed; + video = video.split(/:/); + speed = this.speedToString(video[0]); + this.videos[speed] = video[1]; + }, this); + + return _.isString(this.videos['1.0']); +} + +// function fetchMetadata() +// +// When dealing with YouTube videos, we must fetch meta data that has +// certain key facts not available while the video is loading. For +// example the length of the video can be determined from the meta +// data. +function fetchMetadata() { + let self = this, + metadataXHRs = []; + + this.metadata = {}; + + metadataXHRs = _.map(this.videos, function (url, speed) { + return self.getVideoMetadata(url, function (data) { + if (data.items.length > 0) { + let metaDataItem = data.items[0]; + self.metadata[metaDataItem.id] = metaDataItem.contentDetails; + } + }); + }); + + $.when.apply(this, metadataXHRs).done(function () { + self.el.trigger('metadata_received'); + + // Not only do we trigger the "metadata_received" event, we also + // set a flag to notify that metadata has been received. This + // allows for code that will miss the "metadata_received" event + // to know that metadata has been received. This is important in + // cases when some code will subscribe to the "metadata_received" + // event after it has been triggered. + self.youtubeMetadataReceived = true; + }); +} + +// function parseSpeed() +// +// Create a separate array of available speeds. +function parseSpeed() { + this.speeds = _.keys(this.videos).sort(); +} + +function setSpeed(newSpeed) { + // Possible speeds for each player type. + // HTML5 = [0.75, 1, 1.25, 1.5, 2] + // Youtube Flash = [0.75, 1, 1.25, 1.5] + // Youtube HTML5 = [0.25, 0.5, 1, 1.5, 2] + let map = { + 0.25: '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash + '0.50': '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash + 0.75: '0.50', // HTML5 or Youtube Flash -> Youtube HTML5 + 1.25: '1.50', // HTML5 or Youtube Flash -> Youtube HTML5 + 2.0: '1.50' // HTML5 or Youtube HTML5 -> Youtube Flash + }; + + if (_.contains(this.speeds, newSpeed)) { + this.speed = newSpeed; + } else { + newSpeed = map[newSpeed]; + this.speed = _.contains(this.speeds, newSpeed) ? newSpeed : '1.0'; + } + this.speed = parseFloat(this.speed); +} + +function setAutoAdvance(enabled) { + this.auto_advance = enabled; +} + +function getVideoMetadata(url, callback) { + let youTubeEndpoint; + if (!(_.isString(url))) { + url = this.videos['1.0'] || ''; + } + // Will hit the API URL to get the youtube video metadata. + youTubeEndpoint = this.config.ytMetadataEndpoint; // The new runtime supports anonymous users + // and uses an XBlock handler to get YouTube metadata + if (!youTubeEndpoint) { + // The old runtime has a full/separate LMS API for getting YouTube metadata, but it doesn't + // support anonymous users nor videos that play in a sandboxed iframe. + youTubeEndpoint = [this.config.lmsRootURL, '/courses/yt_video_metadata', '?id=', url].join(''); + } + return $.ajax({ + url: youTubeEndpoint, + success: _.isFunction(callback) ? callback : null, + error: function () { + console.warn( + 'Unable to get youtube video metadata. Some video metadata may be unavailable.' + ); + }, + notifyOnError: false + }); +} + +function youtubeId(speed) { + let currentSpeed = this.isFlashMode() ? this.speed : '1.0'; + + return this.videos[speed] + || this.videos[currentSpeed] + || this.videos['1.0']; +} + +function getDuration() { + try { + return moment.duration(this.metadata[this.youtubeId()].duration, moment.ISO_8601).asSeconds(); + } catch (err) { + return _.result(this.metadata[this.youtubeId('1.0')], 'duration') || 0; + } +} + +/** +* Sets player mode. +* +* @param {string} mode Mode to set for the video player if it is supported. +* Otherwise, `html5` is used by default. +*/ +function setPlayerMode(mode) { + let supportedModes = ['html5', 'flash']; + + mode = _.contains(supportedModes, mode) ? mode : 'html5'; + this.currentPlayerMode = mode; +} + +/** +* Returns current player mode. +* +* @return {string} Returns string that describes player mode +*/ +function getPlayerMode() { + return this.currentPlayerMode; +} + +/** +* Checks if current player mode is Flash. +* +* @return {boolean} Returns `true` if current mode is `flash`, otherwise +* it returns `false` +*/ +function isFlashMode() { + return this.getPlayerMode() === 'flash'; +} + +/** +* Checks if current player mode is Html5. +* +* @return {boolean} Returns `true` if current mode is `html5`, otherwise +* it returns `false` +*/ +function isHtml5Mode() { + return this.getPlayerMode() === 'html5'; +} + +function isYoutubeType() { + return this.videoType === 'youtube'; +} + +function speedToString(speed) { + return parseFloat(speed).toFixed(2).replace(/\.00$/, '.0'); +} + +function getCurrentLanguage() { + let keys = _.keys(this.config.transcriptLanguages); + + if (keys.length) { + if (!_.contains(keys, this.lang)) { + if (_.contains(keys, 'en')) { + this.lang = 'en'; + } else { + this.lang = keys.pop(); + } + } + } else { + return null; + } + + return this.lang; +} + +/* +* The trigger() function will assume that the @objChain is a complete +* chain with a method (function) at the end. It will call this function. +* So for example, when trigger() is called like so: +* +* state.trigger('videoPlayer.pause', {'param1': 10}); +* +* Then trigger() will execute: +* +* state.videoPlayer.pause({'param1': 10}); +*/ +function trigger(objChain) { + let extraParameters = Array.prototype.slice.call(arguments, 1), + i, tmpObj, chain; + + // Remember that 'this' is the 'state' object. + tmpObj = this; + chain = objChain.split('.'); + + // At the end of the loop the letiable 'tmpObj' will either be the + // correct object/function to trigger/invoke. If the 'chain' chain of + // object is incorrect (one of the link is non-existent), then the loop + // will immediately exit. + while (chain.length) { + i = chain.shift(); + + if (tmpObj.hasOwnProperty(i)) { + tmpObj = tmpObj[i]; + } else { + // An incorrect object chain was specified. + + return false; + } + } + + tmpObj.apply(this, extraParameters); + + return true; +} +export { Initialize } \ No newline at end of file diff --git a/xmodule/assets/video/public/js/iterator.js b/xmodule/assets/video/public/js/iterator.js new file mode 100644 index 000000000000..59d2ba2bbfb5 --- /dev/null +++ b/xmodule/assets/video/public/js/iterator.js @@ -0,0 +1,78 @@ +'use strict'; + +/** + * Provides a convenient way to work with iterable data. + * @constructor + * @param {Array} list Array to be iterated. + */ +function Iterator(list) { + this.list = list; + this.index = 0; + this.size = this.list.length; + this.lastIndex = this.list.length - 1; +} + +Iterator.prototype = { + /** + * Checks validity of the provided index for the iterator. + * @param {number} index + * @return {boolean} + */ + _isValid(index) { + return _.isNumber(index) && index < this.size && index >= 0; + }, + + /** + * Returns the next element. + * @param {number} [index] Updates current position. + * @return {any} + */ + next(index) { + if (!this._isValid(index)) { + index = this.index; + } + + this.index = (index >= this.lastIndex) ? 0 : index + 1; + return this.list[this.index]; + }, + + /** + * Returns the previous element. + * @param {number} [index] Updates current position. + * @return {any} + */ + prev(index) { + if (!this._isValid(index)) { + index = this.index; + } + + this.index = (index < 1) ? this.lastIndex : index - 1; + return this.list[this.index]; + }, + + /** + * Returns the last element in the list. + * @return {any} + */ + last() { + return this.list[this.lastIndex]; + }, + + /** + * Returns the first element in the list. + * @return {any} + */ + first() { + return this.list[0]; + }, + + /** + * Returns `true` if the current position is the last for the iterator. + * @return {boolean} + */ + isEnd() { + return this.index === this.lastIndex; + } +}; + +export { Iterator }; \ No newline at end of file diff --git a/xmodule/assets/video/public/js/play_pause_control.js b/xmodule/assets/video/public/js/play_pause_control.js new file mode 100644 index 000000000000..c2b2bdb2aa0e --- /dev/null +++ b/xmodule/assets/video/public/js/play_pause_control.js @@ -0,0 +1,95 @@ +import $ from 'jquery'; // jQuery import +import _ from 'underscore'; + +'use strict'; + +/** + * PlayPauseControl function + * + * @constructor + * @param {Object} state - The object containing the state of the video + * @param {Object} i18n - The object containing strings with translations + * @return {jQuery.Promise} - A resolved jQuery promise + */ +function VideoPlayPauseControl(state, i18n) { + if (!(this instanceof VideoPlayPauseControl)) { + return new VideoPlayPauseControl(state, i18n); + } + + _.bindAll(this, 'play', 'pause', 'onClick', 'destroy'); + this.state = state; + this.state.videoPlayPauseControl = this; + this.i18n = i18n; + + this.initialize(); + + return $.Deferred().resolve().promise(); +} + +VideoPlayPauseControl.prototype = { + template: [ + '', + ].join(''), + + /** Initializes the module. */ + initialize: function () { + this.el = $(this.template); + this.render(); + this.bindHandlers(); + }, + + /** + * Creates any necessary DOM elements, attach them, and set their, + * initial configuration. + */ + render: function () { + this.state.el.find('.vcr').prepend(this.el); + }, + + /** Bind any necessary function callbacks to DOM events. */ + bindHandlers: function () { + this.el.on('click', this.onClick); + this.state.el.on({ + play: this.play, + 'pause ended': this.pause, + destroy: this.destroy, + }); + }, + + onClick: function (event) { + event.preventDefault(); + this.state.videoCommands.execute('togglePlayback'); + }, + + play: function () { + this.el + .addClass('pause') + .removeClass('play') + .attr({title: gettext('Pause'), 'aria-label': gettext('Pause')}) + .find('.icon') + .removeClass('fa-play') + .addClass('fa-pause'); + }, + + pause: function () { + this.el + .removeClass('pause') + .addClass('play') + .attr({title: gettext('Play'), 'aria-label': gettext('Play')}) + .find('.icon') + .removeClass('fa-pause') + .addClass('fa-play'); + }, + + destroy: function () { + this.el.remove(); + this.state.el.off('destroy', this.destroy); + delete this.state.videoPlayPauseControl; + }, +}; + +export {VideoPlayPauseControl}; diff --git a/xmodule/assets/video/public/js/play_placeholder.js b/xmodule/assets/video/public/js/play_placeholder.js new file mode 100644 index 000000000000..d3d7384573bb --- /dev/null +++ b/xmodule/assets/video/public/js/play_placeholder.js @@ -0,0 +1,87 @@ +import $ from 'jquery'; // jQuery import +import _ from 'underscore'; + +'use strict'; + +/** + * Video Play placeholder control function. + * + * @constructor + * @param {Object} state - The object containing the state of the video + * @param {Object} i18n - The object containing strings with translations + * @return {jQuery.Promise} - A resolved jQuery promise + */ +function VideoPlayPlaceholder(state, i18n) { + if (!(this instanceof VideoPlayPlaceholder)) { + return new VideoPlayPlaceholder(state, i18n); + } + + _.bindAll(this, 'onClick', 'hide', 'show', 'destroy'); + this.state = state; + this.state.videoPlayPlaceholder = this; + this.i18n = i18n; + this.initialize(); + + return $.Deferred().resolve().promise(); +} + +VideoPlayPlaceholder.prototype = { + destroy: function () { + this.el.off('click', this.onClick); + this.state.el.off({ + destroy: this.destroy, + play: this.hide, + 'ended pause': this.show, + }); + this.hide(); + delete this.state.videoPlayPlaceholder; + }, + + /** + * Indicates whether the placeholder should be shown. + * We display it for HTML5 videos on iPad and Android devices. + * @return {Boolean} + */ + shouldBeShown: function () { + return /iPad|Android/i.test(this.state.isTouch[0]) && !this.state.isYoutubeType(); + }, + + /** Initializes the module. */ + initialize: function () { + if (!this.shouldBeShown()) { + return false; + } + + this.el = this.state.el.find('.btn-play'); + this.bindHandlers(); + this.show(); + }, + + /** Bind any necessary function callbacks to DOM events. */ + bindHandlers: function () { + this.el.on('click', this.onClick); + this.state.el.on({ + destroy: this.destroy, + play: this.hide, + 'ended pause': this.show, + }); + }, + + onClick: function () { + this.state.videoCommands.execute('play'); + }, + + hide: function () { + this.el + .addClass('is-hidden') + .attr({'aria-hidden': 'true', tabindex: -1}); + }, + + show: function () { + this.el + .removeClass('is-hidden') + .attr({'aria-hidden': 'false', tabindex: 0}); + } +}; + +export {VideoPlayPlaceholder}; \ No newline at end of file diff --git a/xmodule/assets/video/public/js/play_skip_control.js b/xmodule/assets/video/public/js/play_skip_control.js new file mode 100644 index 000000000000..3be7d8b05398 --- /dev/null +++ b/xmodule/assets/video/public/js/play_skip_control.js @@ -0,0 +1,90 @@ +import $ from 'jquery'; // jQuery import +import _ from 'underscore'; + +'use strict'; + +/** + * Play/skip control module + * + * @constructor + * @param {Object} state - The object containing the state of the video + * @param {Object} i18n - The object containing strings with translations + * @return {jQuery.Promise} - A resolved jQuery promise + */ +function VideoPlaySkipControl(state, i18n) { + if (!(this instanceof VideoPlaySkipControl)) { + return new VideoPlaySkipControl(state, i18n); + } + + _.bindAll(this, 'play', 'onClick', 'destroy'); + this.state = state; + this.state.videoPlaySkipControl = this; + this.i18n = i18n; + + this.initialize(); + + return $.Deferred().resolve().promise(); +} + +VideoPlaySkipControl.prototype = { + template: [ + '', + ].join(''), + + /** Initializes the module. */ + initialize: function () { + this.el = $(this.template); + this.render(); + this.bindHandlers(); + }, + + /** + * Creates any necessary DOM elements, attach them, and set their, + * initial configuration. + */ + render: function () { + this.state.el.find('.vcr').prepend(this.el); + }, + + /** Bind any necessary function callbacks to DOM events. */ + bindHandlers: function () { + this.el.on('click', this.onClick); + this.state.el.on({ + play: this.play, + destroy: this.destroy, + }); + }, + + onClick: function (event) { + event.preventDefault(); + if (this.state.videoPlayer.isPlaying()) { + this.state.videoCommands.execute('skip'); + } else { + this.state.videoCommands.execute('play'); + } + }, + + play: function () { + this.el + .removeClass('play') + .addClass('skip') + .attr('title', gettext('Skip')) + .find('.icon') + .removeClass('fa-play') + .addClass('fa-step-forward'); + // Disable possibility to pause the video. + this.state.el.find('video').off('click'); + }, + + destroy: function () { + this.el.remove(); + this.state.el.off('destroy', this.destroy); + delete this.state.videoPlaySkipControl; + }, +}; + +export {VideoPlaySkipControl}; \ No newline at end of file diff --git a/xmodule/assets/video/public/js/poster.js b/xmodule/assets/video/public/js/poster.js new file mode 100644 index 000000000000..ef059b81fa08 --- /dev/null +++ b/xmodule/assets/video/public/js/poster.js @@ -0,0 +1,69 @@ +import $ from 'jquery'; // jQuery import +import _ from 'underscore'; + +'use strict'; + +/** + * VideoPoster function + * + * @constructor + * @param {jQuery} element + * @param {Object} options + */ +function VideoPoster(element, options) { + if (!(this instanceof VideoPoster)) { + return new VideoPoster(element, options); + } + + _.bindAll(this, 'onClick', 'destroy'); + this.element = element; + this.container = element.find('.video-player'); + this.options = options || {}; + this.initialize(); +} + +VideoPoster.moduleName = 'Poster'; +VideoPoster.prototype = { + template: _.template([ + '
', + '', + '
' + ].join('')), + + initialize: function () { + this.el = $(this.template({ + url: this.options.poster.url, + type: this.options.poster.type + })); + this.element.addClass('is-pre-roll'); + this.render(); + this.bindHandlers(); + }, + + bindHandlers: function () { + this.el.on('click', this.onClick); + this.element.on('destroy', this.destroy); + }, + + render: function () { + this.container.append(this.el); + }, + + onClick: function () { + if (_.isFunction(this.options.onClick)) { + this.options.onClick(); + } + this.destroy(); + }, + + destroy: function () { + this.element.off('destroy', this.destroy).removeClass('is-pre-roll'); + this.el.remove(); + }, +}; + +export {VideoPoster}; \ No newline at end of file diff --git a/xmodule/assets/video/public/js/resizer.js b/xmodule/assets/video/public/js/resizer.js new file mode 100644 index 000000000000..bd1382be16e1 --- /dev/null +++ b/xmodule/assets/video/public/js/resizer.js @@ -0,0 +1,191 @@ +import _ from 'underscore'; + +class Resizer { + constructor(params) { + const defaults = { + container: window, + element: null, + containerRatio: null, + elementRatio: null + }; + this.callbacksList = []; + this.delta = { + height: 0, + width: 0 + }; + this.mode = null; + this.config = { ...defaults, ...params }; + + if (!this.config.element) { + console.log('Required parameter `element` is not passed.'); + } + + this.callbacks = { + add: this.addCallback.bind(this), + once: this.addOnceCallback.bind(this), + remove: this.removeCallback.bind(this), + removeAll: this.removeCallbacks.bind(this) + }; + this.deltaApi = { + add: this.addDelta.bind(this), + substract: this.substractDelta.bind(this), + reset: this.resetDelta.bind(this) + }; + } + + getData() { + const $container = this.config.container instanceof Window ? this.config.container : this.config.container; + const containerWidth = ($container === window ? $container.innerWidth : $container.offsetWidth) + this.delta.width; + const containerHeight = ($container === window ? $container.innerHeight : $container.offsetHeight) + this.delta.height; + let containerRatio = this.config.containerRatio; + + const $element = this.config.element; + let elementRatio = this.config.elementRatio; + + if (!containerRatio) { + containerRatio = containerWidth / containerHeight; + } + + if (!elementRatio && $element) { + elementRatio = $element.offsetWidth / $element.offsetHeight; + } + + return { + containerWidth, + containerHeight, + containerRatio, + element: $element, + elementRatio + }; + } + + align() { + const data = this.getData(); + + switch (this.mode) { + case 'height': + this.alignByHeightOnly(data); + break; + case 'width': + this.alignByWidthOnly(data); + break; + default: + if (data.containerRatio >= data.elementRatio) { + this.alignByHeightOnly(data); + } else { + this.alignByWidthOnly(data); + } + break; + } + + this.fireCallbacks(); + return this; + } + + alignByWidthOnly(data = this.getData()) { + if (!data.element) return this; + const height = data.containerWidth / data.elementRatio; + data.element.style.height = `${height}px`; + data.element.style.width = `${data.containerWidth}px`; + data.element.style.top = `${0.5 * (data.containerHeight - height)}px`; + data.element.style.left = '0px'; + return this; + } + + alignByHeightOnly(data = this.getData()) { + if (!data.element) return this; + const width = data.containerHeight * data.elementRatio; + data.element.style.height = `${data.containerHeight}px`; + data.element.style.width = `${width}px`; + data.element.style.top = '0px'; + data.element.style.left = `${0.5 * (data.containerWidth - width)}px`; + return this; + } + + setMode(param) { + if (_.isString(param)) { + this.mode = param; + this.align(); + } + return this; + } + + setElement(element) { + this.config.element = element; + return this; + } + + addCallback(func) { + if (_.isFunction(func)) { + this.callbacksList.push(func); + } else { + console.error('[Video info]: TypeError: Argument is not a function.'); + } + return this; + } + + addOnceCallback(func) { + if (_.isFunction(func)) { + const decorator = () => { + func(); + this.removeCallback(func); + }; + this.addCallback(decorator); + } else { + console.error('TypeError: Argument is not a function.'); + } + return this; + } + + fireCallbacks() { + this.callbacksList.forEach(callback => callback()); + } + + removeCallbacks() { + this.callbacksList.length = 0; + return this; + } + + removeCallback(func) { + const index = this.callbacksList.indexOf(func); + if (index !== -1) { + return this.callbacksList.splice(index, 1); + } + return undefined; + } + + resetDelta() { + this.delta.height = 0; + this.delta.width = 0; + return this; + } + + addDelta(value, side) { + if (_.isNumber(value) && _.isNumber(this.delta[side])) { + this.delta[side] += value; + } + return this; + } + + substractDelta(value, side) { + if (_.isNumber(value) && _.isNumber(this.delta[side])) { + this.delta[side] -= value; + } + return this; + } + + destroy() { + const data = this.getData(); + if (data.element) { + data.element.style.height = ''; + data.element.style.width = ''; + data.element.style.top = ''; + data.element.style.left = ''; + } + this.removeCallbacks(); + this.resetDelta(); + this.mode = null; + } +} + +export { Resizer }; \ No newline at end of file diff --git a/xmodule/assets/video/public/js/save_state_plugin.js b/xmodule/assets/video/public/js/save_state_plugin.js new file mode 100644 index 000000000000..524699fd923b --- /dev/null +++ b/xmodule/assets/video/public/js/save_state_plugin.js @@ -0,0 +1,189 @@ +import $ from 'jquery'; +import _ from 'underscore'; +import Time from 'time.js'; + +'use strict'; + +/** + * Save state module. + * + * @constructor + * @param {Object} state - The object containing the state of the video + * @param {Object} i18n - The object containing strings with translations + * @param {Object} options - Options (e.g., events to handle) + * @return {jQuery.Promise} - A resolved jQuery promise + */ +function VideoSaveStatePlugin(state, i18n, options) { + if (!(this instanceof VideoSaveStatePlugin)) { + return new VideoSaveStatePlugin(state, i18n, options); + } + + _.bindAll( + this, + 'onSpeedChange', + 'onAutoAdvanceChange', + 'saveStateHandler', + 'bindUnloadHandler', + 'onUnload', + 'onYoutubeAvailability', + 'onLanguageChange', + 'destroy' + ); + + this.state = state; + this.options = _.extend({events: []}, options); + this.state.videoSaveStatePlugin = this; + this.i18n = i18n; + this.initialize(); + + return $.Deferred().resolve().promise(); +} + +VideoSaveStatePlugin.moduleName = 'SaveStatePlugin'; +VideoSaveStatePlugin.prototype = { + /** + * Initializes the save state plugin and binds required handlers + */ + initialize: function () { + this.events = { + speedchange: this.onSpeedChange, + autoadvancechange: this.onAutoAdvanceChange, + play: this.bindUnloadHandler, + 'pause destroy': this.saveStateHandler, + 'language_menu:change': this.onLanguageChange, + youtube_availability: this.onYoutubeAvailability, + }; + this.bindHandlers(); + }, + + /** + * Binds the appropriate event handlers to the state element or user-provided events + */ + bindHandlers: function () { + if (this.options.events.length) { + _.each( + this.options.events, + function (eventName) { + if (_.has(this.events, eventName)) { + const callback = this.events[eventName]; + this.state.el.on(eventName, callback); + } + }, + this + ); + } else { + this.state.el.on(this.events); + } + this.state.el.on('destroy', this.destroy); + }, + + /** + * Binds the unload event handler once + */ + bindUnloadHandler: _.once(function () { + $(window).on('unload.video', this.onUnload); + }), + + /** + * Cleans up the plugin by removing event handlers and deleting the instance + */ + destroy: function () { + this.state.el.off(this.events).off('destroy', this.destroy); + $(window).off('unload.video', this.onUnload); + delete this.state.videoSaveStatePlugin; + }, + + /** + * Handles speed change events + * + * @param {Event} event - The event object + * @param {number} newSpeed - The new playback speed + */ + onSpeedChange: function (event, newSpeed) { + this.saveState(true, {speed: newSpeed}); + this.state.storage.setItem('speed', newSpeed, true); + this.state.storage.setItem('general_speed', newSpeed); + }, + + /** + * Handles auto-advance toggle events + * + * @param {Event} event - The event object + * @param {boolean} enabled - Whether auto-advance is enabled + */ + onAutoAdvanceChange: function (event, enabled) { + this.saveState(true, {auto_advance: enabled}); + this.state.storage.setItem('auto_advance', enabled); + }, + + /** + * Saves the state when triggered directly by an event + */ + saveStateHandler: function () { + this.saveState(true); + }, + + /** + * Saves the state during a `window.unload` event + */ + onUnload: function () { + this.saveState(); + }, + + /** + * Handles language change events + * + * @param {Event} event - The event object + * @param {string} langCode - The new language code + */ + onLanguageChange: function (event, langCode) { + this.state.storage.setItem('language', langCode); + }, + + /** + * Handles YouTube availability changes + * + * @param {Event} event - The event object + * @param {boolean} youtubeIsAvailable - Whether YouTube is available + */ + onYoutubeAvailability: function (event, youtubeIsAvailable) { + if (youtubeIsAvailable !== this.state.config.recordedYoutubeIsAvailable) { + this.saveState(true, {youtube_is_available: youtubeIsAvailable}); + } + }, + + /** + * Saves the current state of the video + * + * @param {boolean} async - Whether to save asynchronously + * @param {Object} [additionalData] - Additional data to save + */ + saveState: function (async, data) { + if (this.state.config.saveStateEnabled) { + if (!($.isPlainObject(data))) { + data = { + saved_video_position: this.state.videoPlayer.currentTime + }; + } + + if (data.speed) { + this.state.storage.setItem('speed', data.speed, true); + } + + if (_.has(data, 'saved_video_position')) { + this.state.storage.setItem('savedVideoPosition', data.saved_video_position, true); + data.saved_video_position = Time.formatFull(data.saved_video_position); + } + + $.ajax({ + url: this.state.config.saveStateUrl, + type: 'POST', + async: !!async, + dataType: 'json', + data: data + }); + } + }, +}; + +export {VideoSaveStatePlugin}; diff --git a/xmodule/assets/video/public/js/sjson.js b/xmodule/assets/video/public/js/sjson.js new file mode 100644 index 000000000000..d1cbf6c90ade --- /dev/null +++ b/xmodule/assets/video/public/js/sjson.js @@ -0,0 +1,137 @@ +import _ from 'underscore'; + +/** + * Creates an object to manage subtitle data. + * + * @param {object} data An object containing subtitle information with `start` (array of start times) and `text` (array of captions). + * @returns {object} An object with methods to access and manipulate subtitle data. + */ +const Sjson = function (data) { + 'use strict'; + + const sjson = { + start: Array.isArray(data.start) ? [...data.start] : [], + text: Array.isArray(data.text) ? [...data.text] : [] + }; + + /** + * Creates a getter function for a specified property of the `sjson` object. + * + * @param {string} propertyName The name of the property to access ('start' or 'text'). + * @returns {function(): Array} A function that returns a copy of the specified array. + */ + const getter = function (propertyName) { + return function () { + return [...sjson[propertyName]]; + }; + }; + + /** + * Returns a copy of the array of start times. + * + * @returns {Array} An array of subtitle start times in seconds. + */ + const getStartTimes = getter('start'); + + /** + * Returns a copy of the array of captions. + * + * @returns {Array} An array of subtitle text. + */ + const getCaptions = getter('text'); + + /** + * Returns the number of captions available. + * + * @returns {number} The total number of captions. + */ + const size = function () { + return sjson.text.length; + }; + + /** + * Searches for the index of the caption that should be displayed at a given time. + * + * @param {number} time The time (in seconds) to search for. + * @param {number} [startTime] An optional start time (in seconds) to filter the search within. + * @param {number} [endTime] An optional end time (in seconds) to filter the search within. + * @returns {number} The index of the caption to display at the given time, or the index of the last caption if the time is beyond the last start time. Returns 0 if no captions are available. + */ + function search(time, startTime, endTime) { + let start = getStartTimes(); + let max = size() - 1; + let min = 0; + let searchResults; + let index; + + if (typeof startTime !== 'undefined' && typeof endTime !== 'undefined') { + searchResults = filter(startTime, endTime); + start = searchResults.start; + max = searchResults.captions.length - 1; + } + + if (start.length === 0) { + return 0; + } + + while (min < max) { + index = Math.ceil((max + min) / 2); + + if (time < start[index]) { + max = index - 1; + } + + if (time >= start[index]) { + min = index; + } + } + + return min; + } + + /** + * Filters captions that occur between a given start and end time. + * + * @param {number} start The start time (in seconds) for filtering. + * @param {number} end The end time (in seconds) for filtering. + * @returns {object} An object with `start` (array of filtered start times) and `captions` (array of corresponding filtered captions). + */ + function filter(start, end) { + const filteredTimes = []; + const filteredCaptions = []; + const startTimes = getStartTimes(); + const captions = getCaptions(); + + if (startTimes.length !== captions.length) { + console.warn('video caption and start time arrays do not match in length'); + } + + let effectiveEnd = end; + if (effectiveEnd === null && startTimes.length) { + effectiveEnd = startTimes[startTimes.length - 1]; + } + + for (let i = 0; i < startTimes.length; i++) { + const currentStartTime = startTimes[i]; + if (currentStartTime >= start && currentStartTime <= effectiveEnd) { + filteredTimes.push(currentStartTime); + filteredCaptions.push(captions[i]); + } + } + + return { + start: filteredTimes, + captions: filteredCaptions + }; + } + + return { + getCaptions, + getStartTimes, + getSize: size, + filter, + search + }; +}; + +export { Sjson }; \ No newline at end of file diff --git a/xmodule/assets/video/public/js/skip_control.js b/xmodule/assets/video/public/js/skip_control.js new file mode 100644 index 000000000000..326c8e8fbb64 --- /dev/null +++ b/xmodule/assets/video/public/js/skip_control.js @@ -0,0 +1,70 @@ +import $ from 'jquery'; // jQuery import +import _ from 'underscore'; + +'use strict'; + +/** + * VideoSkipControl function + * + * @constructor + * @param {Object} state The object containing the state of the video + * @param {Object} i18n The object containing strings with translations + * @return {jQuery.Promise} Returns a resolved jQuery promise + */ +function VideoSkipControl(state, i18n) { + if (!(this instanceof VideoSkipControl)) { + return new VideoSkipControl(state, i18n); + } + + _.bindAll(this, 'onClick', 'render', 'destroy'); + this.state = state; + this.state.videoSkipControl = this; + this.i18n = i18n; + + this.initialize(); + + return $.Deferred().resolve().promise(); +} + +VideoSkipControl.prototype = { + template: [ + '', + ].join(''), + + initialize: function () { + this.el = $(this.template); + this.bindHandlers(); + }, + + /** Creates any necessary DOM elements, attach them, and set their, initial configuration. */ + render: function () { + this.state.el.find('.vcr .control').after(this.el); + }, + + /** Bind any necessary function callbacks to DOM events. */ + bindHandlers: function () { + this.el.on('click', this.onClick); + + this.state.el.on({ + 'play.skip': _.once(this.render), + 'destroy.skip': this.destroy, + }); + }, + + onClick: function (event) { + event.preventDefault(); + this.state.videoCommands.execute('skip', true); + }, + + destroy: function () { + this.el.remove(); + this.state.el.off('.skip'); + delete this.state.videoSkipControl; + }, +}; + +export {VideoSkipControl}; \ No newline at end of file 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_accessible_menu.js b/xmodule/assets/video/public/js/video_accessible_menu.js new file mode 100644 index 000000000000..efcbff1cf63f --- /dev/null +++ b/xmodule/assets/video/public/js/video_accessible_menu.js @@ -0,0 +1,56 @@ +import _ from 'underscore'; + +/** + * Video Download Transcript control module. + * + * @constructor + * @param {jQuery} element + * @param {Object} options + */ +function VideoTranscriptDownloadHandler(element, options = {}) { + if (!(this instanceof VideoTranscriptDownloadHandler)) { + return new VideoTranscriptDownloadHandler(element, options); + } + + _.bindAll(this, 'clickHandler'); + + this.container = element; + this.options = options; + + if (this.container.find('.wrapper-downloads .wrapper-download-transcripts')) { + this.initialize(); + } +} + +VideoTranscriptDownloadHandler.prototype = { + // Initializes the module. + initialize() { + this.value = this.options.storage.getItem('transcript_download_format'); + this.el = this.container.find('.list-download-transcripts'); + this.el.on('click', '.btn-link', this.clickHandler); + }, + + // Event handler. We delay link clicks until the file type is set + clickHandler(event) { + event.preventDefault(); + + const fileType = $(event.target).data('value'); + const data = {transcript_download_format: fileType}; + const downloadUrl = $(event.target).attr('href'); + + $.ajax({ + url: this.options.saveStateUrl, + type: 'POST', + dataType: 'json', + data: data, + success: () => { + this.options.storage.setItem('transcript_download_format', fileType); + }, + complete: () => { + document.location.href = downloadUrl; + }, + }); + }, +}; + +export {VideoTranscriptDownloadHandler}; diff --git a/xmodule/assets/video/public/js/video_auto_advance_control.js b/xmodule/assets/video/public/js/video_auto_advance_control.js new file mode 100644 index 000000000000..d6cba1b5d79d --- /dev/null +++ b/xmodule/assets/video/public/js/video_auto_advance_control.js @@ -0,0 +1,148 @@ +import { interpolateHtml, HTML } from 'edx-ui-toolkit/js/utils/html-utils'; +import _ from 'underscore'; +import * as gettext from 'gettext'; + +/** + * Auto advance control module. + * @exports video/08_video_auto_advance_control.js + * @constructor + * @param {object} state The object containing the state of the video player. + * @return {Promise} + */ +class VideoAutoAdvanceControl { + constructor(state) { + if (!(this instanceof VideoAutoAdvanceControl)) { + return new VideoAutoAdvanceControl(state); + } + + _.bindAll(this, 'onClick', 'destroy', 'autoPlay', 'autoAdvance'); + this.state = state; + this.state.videoAutoAdvanceControl = this; + this.el = null; + this.initialize(); + + return Promise.resolve(); + } + + template = interpolateHtml( + HTML([ + ''].join('')), + { + autoAdvanceText: gettext('Auto-advance') // Assuming gettext is globally available or imported + } + ).toString(); + + destroy() { + if (this.el) { + this.el.removeEventListener('click', this.onClick); + this.el.remove(); + } + if (this.state && this.state.el) { + this.state.el.removeEventListener('ready', this.autoPlay); + this.state.el.removeEventListener('ended', this.autoAdvance); + this.state.el.removeEventListener('destroy', this.destroy); + } + if (this.state) { + delete this.state.videoAutoAdvanceControl; + } + } + + /** Initializes the module. */ + initialize() { + const state = this.state; + + this.el = this.createDOMElement(this.template); + this.render(); + this.setAutoAdvance(state.auto_advance); + this.bindHandlers(); + + return true; + } + + /** + * Creates a DOM element from an HTML string. + * @param {string} htmlString The HTML string to create the element from. + * @returns {HTMLElement} The created DOM element. + */ + createDOMElement(htmlString) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = htmlString.trim(); + return tempDiv.firstChild; + } + + /** + * Creates any necessary DOM elements, attach them, and set their, + * initial configuration. + * @param {boolean} enabled Whether auto advance is enabled + */ + render() { + const secondaryControls = this.state.el.querySelector('.secondary-controls'); + if (secondaryControls && this.el) { + secondaryControls.prepend(this.el); + } + } + + /** + * Bind any necessary function callbacks to DOM events (click, + * mousemove, etc.). + */ + bindHandlers() { + if (this.el) { + this.el.addEventListener('click', this.onClick); + } + if (this.state && this.state.el) { + this.state.el.addEventListener('ready', this.autoPlay); + this.state.el.addEventListener('ended', this.autoAdvance); + this.state.el.addEventListener('destroy', this.destroy); + } + } + + onClick(event) { + const enabled = !this.state.auto_advance; + event.preventDefault(); + this.setAutoAdvance(enabled); + if (this.el) { + this.el.dispatchEvent(new CustomEvent('autoadvancechange', { detail: [enabled] })); + } + } + + /** + * Sets or unsets auto advance. + * @param {boolean} enabled Sets auto advance. + */ + setAutoAdvance(enabled) { + if (this.el) { + if (enabled) { + this.el.classList.add('active'); + } else { + this.el.classList.remove('active'); + } + } + } + + autoPlay() { + // Only autoplay the video if it's the first component of the unit. + // If a unit has more than one video, no more than one will autoplay. + const isFirstComponent = this.state.el.closest('.vert-0'); + if (this.state.auto_advance && isFirstComponent) { + this.state.videoCommands.execute('play'); // Assuming videoCommands is on the state + } + } + + autoAdvance() { + if (this.state.auto_advance) { + const nextButton = document.querySelector('.sequence-nav-button.button-next'); + if (nextButton) { + nextButton.click(); + } + } + } +} + +export { VideoAutoAdvanceControl }; \ No newline at end of file diff --git a/xmodule/assets/video/public/js/video_block_main.js b/xmodule/assets/video/public/js/video_block_main.js new file mode 100644 index 000000000000..5c2026d820b8 --- /dev/null +++ b/xmodule/assets/video/public/js/video_block_main.js @@ -0,0 +1,188 @@ +// Import required modules and dependencies +import $ from 'jquery'; +import _ from 'underscore'; +import {VideoStorage} from './video_storage'; +import {VideoPoster} from './poster'; +import {VideoTranscriptDownloadHandler} from './video_accessible_menu'; +import {Initialize} from './initialize'; + +// 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 + +// Stub gettext if the runtime doesn't provide it +if (typeof window.gettext === 'undefined') { + window.gettext = function (text) { + return text; + }; +} + + +'use strict'; + +console.log('In video_block_main.js file'); + +(function () { + var youtubeXhr = null; + var oldVideo = window.Video; + + window.Video = function (runtime, element) { + console.log('In Video initialize method'); + + const el = $(element).find('.video'); + const id = el.attr('id').replace(/video_/, ''); + const storage = new VideoStorage('VideoState', id); + const bumperMetadata = el.data('bumper-metadata'); + const autoAdvanceEnabled = el.data('autoadvance-enabled') === 'True'; + + 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, + // VideoVolumeControl, + // VideoCaption, + // VideoCommands, + // VideoSaveStatePlugin, + // VideoTranscriptFeedback, + // VideoEventsBumperPlugin, + // VideoCompletionHandler, + ]; + + const state = { + el: el, + id: id, + metadata: el.data('metadata'), + storage: storage, + options: {}, + youtubeXhr: youtubeXhr, + modules: mainVideoModules, + }; + + const getBumperState = (metadata) => { + return $.extend(true, { + el: el, + id: id, + storage: storage, + options: {SaveStatePlugin: {events: ['language_menu:change']}}, + youtubeXhr: youtubeXhr, + modules: bumperVideoModules, + }, {metadata: metadata}); + }; + + const player = (innerState) => { + return () => { + _.extend(innerState.metadata, {autoplay: true, focusFirstControl: true}); + // TODO: Uncomment following initialize method calling + Initialize(innerState, element); + }; + }; + + VideoTranscriptDownloadHandler(el, { + storage: storage, + saveStateUrl: state.metadata.saveStateUrl, + }); + + // VideoSocialSharing(el); + + if (bumperMetadata) { + VideoPoster(el, { + poster: el.data('poster'), + onClick: _.once(function () { + const mainVideoPlayer = player(state); + + if (storage.getItem('isBumperShown')) { + mainVideoPlayer(); + } else { + const bumperState = getBumperState(bumperMetadata); + const bumper = new VideoBumper(player(bumperState), bumperState); + + state.bumperState = bumperState; + + bumper.getPromise().then(() => { + delete state.bumperState; + mainVideoPlayer(); + }); + } + }), + }); + } else { + // TODO: Uncomment following initialize method calling + Initialize(state, element); + } + + if (!youtubeXhr) { + youtubeXhr = state.youtubeXhr; + } + + el.data('video-player-state', state); + const onSequenceChange = () => { + if (state && state.videoPlayer) { + state.videoPlayer.destroy(); + } + $('.sequence').off('sequence:change', onSequenceChange); + }; + $('.sequence').on('sequence:change', onSequenceChange); + + // Because the 'state' object is only available inside this closure, we will also make it available to + // the caller by returning it. This is necessary so that we can test Video with Jasmine. + return state; + }; + + window.Video.clearYoutubeXhr = function () { + youtubeXhr = null; + }; + + // TODO: Uncomment following initialize related code + // window.Video.loadYouTubeIFrameAPI = initialize.prototype.loadYouTubeIFrameAPI; + + // Invoke the mock Video constructor so that the elements stored within it can be processed by the real + // `window.Video` constructor. + // TODO: Un comment following + // oldVideo(null, true); + +}()); + diff --git a/xmodule/assets/video/public/js/video_bumper.js b/xmodule/assets/video/public/js/video_bumper.js new file mode 100644 index 000000000000..4ae6f19bcc24 --- /dev/null +++ b/xmodule/assets/video/public/js/video_bumper.js @@ -0,0 +1,111 @@ +'use strict'; +import $ from 'jquery'; + +/** + * VideoBumper module. + * + * @param {Function} player - The player factory function. + * @param {Object} state - Object containing the video state and DOM refs. + * @returns {Promise} A jQuery promise that resolves when bumper ends. + */ +export default class VideoBumper { + constructor(player, state) { + this.dfd = $.Deferred(); + this.element = state.el; + this.player = player; + this.state = state; + this.doNotShowAgain = false; + this.maxBumperDuration = 35; // seconds + + // Attach bumper instance to state for external reference + this.state.videoBumper = this; + + // Style and initialize + this.element.addClass('is-bumper'); + + // Bind class methods to `this` + this.showMainVideoHandler = this.showMainVideoHandler.bind(this); + this.destroy = this.destroy.bind(this); + this.skipByDuration = this.skipByDuration.bind(this); + this.destroyAndResolve = this.destroyAndResolve.bind(this); + + this.bindHandlers(); + this.initialize(); + } + + initialize() { + this.player(); + } + + getPromise() { + return this.dfd.promise(); + } + + showMainVideoHandler() { + this.state.storage.setItem('isBumperShown', true); + setTimeout(() => { + this.saveState(); + this.showMainVideo(); + }, 20); + } + + destroyAndResolve() { + this.destroy(); + this.dfd.resolve(); + } + + showMainVideo() { + if (this.state.videoPlayer) { + this.destroyAndResolve(); + } else { + this.element.on('initialize', this.destroyAndResolve); + } + } + + skip() { + this.element.trigger('skip', [this.doNotShowAgain]); + this.showMainVideoHandler(); + } + + skipAndDoNotShowAgain() { + this.doNotShowAgain = true; + this.skip(); + } + + skipByDuration(event, time) { + if (time > this.maxBumperDuration) { + this.element.trigger('ended'); + } + } + + bindHandlers() { + this.element.on('ended error', this.showMainVideoHandler); + this.element.on('timeupdate', this.skipByDuration); + } + + saveState() { + const info = { bumper_last_view_date: true }; + if (this.doNotShowAgain) { + Object.assign(info, { bumper_do_not_show_again: true }); + } + + if (this.state.videoSaveStatePlugin) { + this.state.videoSaveStatePlugin.saveState(true, info); + } + } + + destroy() { + this.element.off('ended error', this.showMainVideoHandler); + this.element.off({ + timeupdate: this.skipByDuration, + initialize: this.destroyAndResolve + }); + this.element.removeClass('is-bumper'); + + if (typeof this.state.videoPlayer?.destroy === 'function') { + this.state.videoPlayer.destroy(); + } + + delete this.state.videoBumper; + } +} 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 = $('