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/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/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/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/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/video_block_main.js b/xmodule/assets/video/public/js/video_block_main.js index 902c6e704f21..c3f7372fb8dc 100644 --- a/xmodule/assets/video/public/js/video_block_main.js +++ b/xmodule/assets/video/public/js/video_block_main.js @@ -4,31 +4,32 @@ import _ from 'underscore'; import {VideoStorage} from './video_storage'; import {VideoPoster} from './poster'; import {VideoTranscriptDownloadHandler} from './video_accessible_menu'; +import {VideoSkipControl} from './skip_control'; +import {VideoPlayPlaceholder} from './play_placeholder'; +import {VideoPlaySkipControl} from './play_skip_control'; +import {VideoPlayPauseControl} from './play_pause_control'; +import {VideoSocialSharingHandler} from './video_social_sharing'; +import {FocusGrabber} from './focus_grabber'; +import {VideoCommands} from "./commands"; +import {VideoEventsBumperPlugin} from "./events_bumper_plugin"; +import {VideoEventsPlugin} from "./events_plugin"; +import {VideoSaveStatePlugin} from "./save_state_plugin"; + // TODO: Uncomment the imports // import { initialize } from './initialize'; // Assuming this function is imported // import { -// FocusGrabber, // VideoControl, -// VideoPlayPlaceholder, -// VideoPlayPauseControl, // VideoProgressSlider, // VideoSpeedControl, // VideoVolumeControl, // VideoQualityControl, // VideoFullScreen, // VideoCaption, -// VideoCommands, // VideoContextMenu, -// VideoSaveStatePlugin, -// VideoEventsPlugin, // VideoCompletionHandler, // VideoTranscriptFeedback, // VideoAutoAdvanceControl, -// VideoPlaySkipControl, -// VideoSkipControl, -// VideoEventsBumperPlugin, -// VideoSocialSharing, // VideoBumper, // } from './video_modules'; // Assuming all necessary modules are grouped here @@ -57,37 +58,36 @@ console.log('In video_block_main.js file'); 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 mainVideoModules = [ + FocusGrabber, + // VideoControl, + VideoPlayPlaceholder, + VideoPlayPauseControl, + // VideoProgressSlider, + // VideoSpeedControl, + // VideoVolumeControl, + // VideoQualityControl, + // VideoFullScreen, + // VideoCaption, + VideoCommands, + // VideoContextMenu, + VideoSaveStatePlugin, + VideoEventsPlugin, + // VideoCompletionHandler, + // VideoTranscriptFeedback, + // ].concat(autoAdvanceEnabled ? [VideoAutoAdvanceControl] : []); + ] const bumperVideoModules = [ // VideoControl, - // VideoPlaySkipControl, - // VideoSkipControl, + VideoPlaySkipControl, + VideoSkipControl, // VideoVolumeControl, // VideoCaption, - // VideoCommands, - // VideoSaveStatePlugin, + VideoCommands, + VideoSaveStatePlugin, // VideoTranscriptFeedback, - // VideoEventsBumperPlugin, + VideoEventsBumperPlugin, // VideoCompletionHandler, ]; @@ -125,7 +125,7 @@ console.log('In video_block_main.js file'); saveStateUrl: state.metadata.saveStateUrl, }); - // VideoSocialSharing(el); + VideoSocialSharingHandler(el); if (bumperMetadata) { VideoPoster(el, { @@ -184,4 +184,3 @@ console.log('In video_block_main.js file'); // oldVideo(null, true); }()); - diff --git a/xmodule/assets/video/public/js/video_social_sharing.js b/xmodule/assets/video/public/js/video_social_sharing.js new file mode 100644 index 000000000000..6d8bd19e5413 --- /dev/null +++ b/xmodule/assets/video/public/js/video_social_sharing.js @@ -0,0 +1,77 @@ +import $ from 'jquery'; +import _ from 'underscore'; + +'use strict'; + +/** + * Video Social Sharing control module. + * + * @constructor + * @param {jQuery.Element} element - The container element for the video social sharing controls + * @param {Object} options - Additional options for the module + */ +function VideoSocialSharingHandler(element, options) { + if (!(this instanceof VideoSocialSharingHandler)) { + return new VideoSocialSharingHandler(element, options); + } + + _.bindAll(this, 'clickHandler', 'copyHandler', 'hideHandler', 'showHandler'); + + this.container = element; + + if (this.container.find('.wrapper-downloads .wrapper-social-share')) { + this.initialize(); + } + + return false; +} + +VideoSocialSharingHandler.prototype = { + // Initializes the module. + initialize: function () { + this.el = this.container.find('.wrapper-social-share'); + this.baseVideoUrl = this.el.data('url'); + this.course_id = this.container.data('courseId'); + this.block_id = this.container.data('blockId'); + this.el.on('click', '.social-share-link', this.clickHandler); + + this.closeBtn = this.el.find('.close-btn'); + this.toggleBtn = this.el.find('.social-toggle-btn'); + this.copyBtn = this.el.find('.public-video-copy-btn'); + this.shareContainer = this.el.find('.container-social-share'); + this.closeBtn.on('click', this.hideHandler); + this.toggleBtn.on('click', this.showHandler); + this.copyBtn.on('click', this.copyHandler); + }, + + // Fire an analytics event on share button click. + clickHandler: function (event) { + const source = $(event.currentTarget).data('source'); + this.sendAnalyticsEvent(source); + }, + + hideHandler: function (event) { + this.shareContainer.hide(); + this.toggleBtn.show(); + }, + + showHandler: function (event) { + this.shareContainer.show(); + this.toggleBtn.hide(); + }, + + copyHandler: function (event) { + navigator.clipboard.writeText(this.copyBtn.data('url')); + }, + + // Send an analytics event for share button tracking. + sendAnalyticsEvent: function (source) { + window.analytics.track('edx.social.video.share_button.clicked', { + source: source, + video_block_id: this.container.data('blockId'), + course_id: this.container.data('courseId'), + }); + }, +}; + +export {VideoSocialSharingHandler}; \ No newline at end of file