diff --git a/cms/envs/common.py b/cms/envs/common.py index 2e7460006b..ec796329fc 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -170,6 +170,10 @@ FEATURES = { # Don't autoplay videos for course authors 'AUTOPLAY_VIDEOS': False, + # Move the course author to next page when a video finishes. Set to True to + # show an auto-advance button in videos. If False, videos never auto-advance. + 'ENABLE_AUTOADVANCE_VIDEOS': False, + # If set to True, new Studio users won't be able to author courses unless # an Open edX admin has added them to the course creator group. 'ENABLE_CREATOR_GROUP': True, diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index 1432447709..b99e4afd18 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -458,6 +458,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark .volume, .add-fullscreen, .grouped-controls, + .auto-advance, .quality-control { @include border-left(1px dotted rgb(79, 89, 93)); // UXPL grayscale-cool x-dark } @@ -465,6 +466,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark .speed-button, .volume > .control, .add-fullscreen, + .auto-advance, .quality-control, .toggle-transcript { &:focus { diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_autoadvance.html b/common/lib/xmodule/xmodule/js/fixtures/video_autoadvance.html new file mode 100644 index 0000000000..d6deeaf3d5 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/fixtures/video_autoadvance.html @@ -0,0 +1,35 @@ + +
+
+
+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+
+
diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_autoadvance_disabled.html b/common/lib/xmodule/xmodule/js/fixtures/video_autoadvance_disabled.html new file mode 100644 index 0000000000..0783804353 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/fixtures/video_autoadvance_disabled.html @@ -0,0 +1,35 @@ + +
+
+
+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+
+
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_autoadvance_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_autoadvance_spec.js new file mode 100644 index 0000000000..ee4c3a0713 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/video/video_autoadvance_spec.js @@ -0,0 +1,109 @@ +(function() { + 'use strict'; + describe('VideoAutoAdvance', function() { + var state, oldOTBD; + beforeEach(function() { + oldOTBD = window.onTouchBasedDevice; + window.onTouchBasedDevice = jasmine + .createSpy('onTouchBasedDevice').and.returnValue(null); + jasmine.clock().install(); + }); + afterEach(function() { + $('source').remove(); + state.storage.clear(); + + if (state.videoPlayer) { + state.videoPlayer.destroy(); + } + window.onTouchBasedDevice = oldOTBD; + jasmine.clock().uninstall(); + }); + describe('when auto-advance feature is unset (default behaviour)', function() { + beforeEach(function() { + state = jasmine.initializePlayer('video.html'); + appendLoadFixtures('sequence.html'); + }); + it('no auto-advance button is shown', function() { + var $button = $('.control.auto-advance'); + expect($button).not.toExist(); + }); + it('when video ends, it will not auto-advance to next unit', function() { + var $nextButton = $('.sequence-nav-button.button-next').first(); + expect($nextButton).toExist(); + + // not auto-clicked yet + spyOnEvent($nextButton, 'click'); + expect('click').not.toHaveBeenTriggeredOn($nextButton); + + state.el.trigger('ended'); + jasmine.clock().tick(2); + + // still not auto-clicked + expect('click').not.toHaveBeenTriggeredOn($nextButton); + }); + }); + describe('when auto-advance feature is set', function() { + describe('and auto-advance is enabled', function() { + beforeEach(function() { + state = jasmine.initializePlayer('video_autoadvance.html'); + appendLoadFixtures('sequence.html'); + }); + it('an active auto-advance button is shown', function() { + var $button = $('.control.auto-advance'); + expect($button).toExist(); + expect($button).toHaveClass('active'); + }); + it('when button is clicked, it will deactivate auto-advance', function() { + var $button = $('.control.auto-advance'); + $button.click(); + expect($button).not.toHaveClass('active'); + }); + it('when video ends, it will auto-advance to next unit', function() { + var $nextButton = $('.sequence-nav-button.button-next').first(); + expect($nextButton).toExist(); + + // not auto-clicked yet + spyOnEvent($nextButton, 'click'); + expect('click').not.toHaveBeenTriggeredOn($nextButton); + + state.el.trigger('ended'); + jasmine.clock().tick(2); + + // now it was auto-clicked + expect('click').toHaveBeenTriggeredOn($nextButton); + }); + }); + + describe('when auto-advance is disabled', function() { + beforeEach(function() { + state = jasmine.initializePlayer('video_autoadvance_disabled.html'); + appendLoadFixtures('sequence.html'); + }); + it('an inactive auto-advance button is shown', function() { + var $button = $('.control.auto-advance'); + expect($button).toExist(); + expect($button).not.toHaveClass('active'); + }); + it('when the button is clicked, it will activate auto-advance', function() { + var $button = $('.control.auto-advance'); + $button.click(); + expect($button).toHaveClass('active'); + }); + it('when video ends, it will not auto-advance to next unit', function() { + var $nextButton = $('.sequence-nav-button.button-next').first(); + expect($nextButton).toExist(); + + // not auto-clicked yet + spyOnEvent($nextButton, 'click'); + expect('click').not.toHaveBeenTriggeredOn($nextButton); + + state.el.trigger('ended'); + jasmine.clock().tick(2); + + // still not auto-clicked + expect('click').not.toHaveBeenTriggeredOn($nextButton); + }); + }); + }); + }); +}).call(this); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js index e0120b165f..f6221117ec 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js @@ -201,6 +201,7 @@ seek: plugin.onSeek, skip: plugin.onSkip, speedchange: plugin.onSpeedChange, + autoadvancechange: plugin.onAutoAdvanceChange, 'language_menu:show': plugin.onShowLanguageMenu, 'language_menu:hide': plugin.onHideLanguageMenu, 'transcript:show': plugin.onShowTranscript, diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_save_state_plugin_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_save_state_plugin_spec.js index cc71901c6c..0d100a98db 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_save_state_plugin_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_save_state_plugin_spec.js @@ -242,6 +242,7 @@ expect(state.videoSaveStatePlugin).toBeUndefined(); expect($.fn.off).toHaveBeenCalledWith({ speedchange: plugin.onSpeedChange, + autoadvancechange: plugin.onAutoAdvanceChange, play: plugin.bindUnloadHandler, 'pause destroy': plugin.saveStateHandler, 'language_menu:change': plugin.onLanguageChange, diff --git a/common/lib/xmodule/xmodule/js/src/video/00_i18n.js b/common/lib/xmodule/xmodule/js/src/video/00_i18n.js index 435c420565..f9fd9a162e 100644 --- a/common/lib/xmodule/xmodule/js/src/video/00_i18n.js +++ b/common/lib/xmodule/xmodule/js/src/video/00_i18n.js @@ -18,6 +18,7 @@ function() { 'Exit full browser': gettext('Exit full browser'), 'Fill browser': gettext('Fill browser'), Speed: gettext('Speed'), + 'Auto-advance': gettext('Auto-advance'), Volume: gettext('Volume'), // Translators: Volume level equals 0%. Muted: gettext('Muted'), diff --git a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js index 2f7d99a075..93a16f6631 100644 --- a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js +++ b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js @@ -62,6 +62,7 @@ function(VideoPlayer, i18n, moment, _) { }); }, + /* eslint-disable no-use-before-define */ methodsDict = { bindTo: bindTo, fetchMetadata: fetchMetadata, @@ -77,6 +78,7 @@ function(VideoPlayer, i18n, moment, _) { parseYoutubeStreams: parseYoutubeStreams, setPlayerMode: setPlayerMode, setSpeed: setSpeed, + setAutoAdvance: setAutoAdvance, speedToString: speedToString, trigger: trigger, youtubeId: youtubeId, @@ -84,6 +86,7 @@ function(VideoPlayer, i18n, moment, _) { loadYoutubePlayer: loadYoutubePlayer, loadYouTubeIFrameAPI: loadYouTubeIFrameAPI }, + /* eslint-enable no-use-before-define */ _youtubeApiDeferred = null, _oldOnYouTubeIframeAPIReady; @@ -375,6 +378,14 @@ function(VideoPlayer, i18n, moment, _) { showCaptions: isBoolean, autoplay: isBoolean, autohideHtml5: isBoolean, + autoAdvance: function(value) { + var 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) || @@ -568,6 +579,7 @@ function(VideoPlayer, i18n, moment, _) { this.speed = this.speedToString( this.config.speed || this.config.generalSpeed ); + this.auto_advance = this.config.autoAdvance; this.htmlPlayerLoaded = false; this.duration = this.metadata.duration; @@ -704,6 +716,10 @@ function(VideoPlayer, i18n, moment, _) { } } + function setAutoAdvance(enabled) { + this.auto_advance = enabled; + } + function getVideoMetadata(url, callback) { if (!(_.isString(url))) { url = this.videos['1.0'] || ''; diff --git a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js index f9b327ea38..debff1da8b 100644 --- a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js +++ b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js @@ -14,6 +14,7 @@ function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) { return dfd.promise(); }, + /* eslint-disable no-use-before-define */ methodsDict = { destroy: destroy, duration: duration, @@ -41,6 +42,7 @@ function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) { onReady: onReady, onSlideSeek: onSeek, onSpeedChange: onSpeedChange, + onAutoAdvanceChange: onAutoAdvanceChange, onStateChange: onStateChange, onUnstarted: onUnstarted, onVolumeChange: onVolumeChange, @@ -53,6 +55,7 @@ function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) { figureOutStartingTime: figureOutStartingTime, updatePlayTime: updatePlayTime }; + /* eslint-enable no-use-before-define */ VideoPlayer.prototype = methodsDict; @@ -427,6 +430,10 @@ function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) { this.videoPlayer.setPlaybackRate(newSpeed); } + function onAutoAdvanceChange(enabled) { + this.setAutoAdvance(enabled); + } + // Every 200 ms, if the video is playing, we call the function update, via // clearInterval. This interval is called updateInterval. // It is created on a onPlay event. Cleared on a onPause event. @@ -564,6 +571,10 @@ function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) { _this.videoPlayer.onSpeedChange(speed); }); + this.el.on('autoadvancechange', function(event, enabled) { + _this.videoPlayer.onAutoAdvanceChange(enabled); + }); + this.el.on('volumechange volumechange:silent', function(event, volume) { _this.videoPlayer.onVolumeChange(volume); }); diff --git a/common/lib/xmodule/xmodule/js/src/video/08_video_auto_advance_control.js b/common/lib/xmodule/xmodule/js/src/video/08_video_auto_advance_control.js new file mode 100644 index 0000000000..e3935caed5 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/video/08_video_auto_advance_control.js @@ -0,0 +1,129 @@ +(function(requirejs, require, define) { + 'use strict'; + define( + 'video/08_video_auto_advance_control.js', [ + 'edx-ui-toolkit/js/utils/html-utils', + 'underscore' + ], function(HtmlUtils, _) { + /** + * 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 {jquery Promise} + */ + var AutoAdvanceControl = function(state) { + if (!(this instanceof AutoAdvanceControl)) { + return new AutoAdvanceControl(state); + } + + _.bindAll(this, 'onClick', 'destroy', 'autoPlay', 'autoAdvance'); + this.state = state; + this.state.videoAutoAdvanceControl = this; + this.initialize(); + + return $.Deferred().resolve().promise(); + }; + + AutoAdvanceControl.prototype = { + template: HtmlUtils.interpolateHtml( + HtmlUtils.HTML([ + ''].join('')), + { + autoAdvanceText: gettext('Auto-advance') + } + ).toString(), + + destroy: function() { + this.el.off({ + click: this.onClick + }); + this.el.remove(); + this.state.el.off({ + ready: this.autoPlay, + ended: this.autoAdvance, + destroy: this.destroy + }); + delete this.state.videoAutoAdvanceControl; + }, + + /** Initializes the module. */ + initialize: function() { + var state = this.state; + + this.el = $(this.template); + this.render(); + this.setAutoAdvance(state.auto_advance); + this.bindHandlers(); + + return true; + }, + + /** + * Creates any necessary DOM elements, attach them, and set their, + * initial configuration. + * @param {boolean} enabled Whether auto advance is enabled + */ + render: function() { + this.state.el.find('.secondary-controls').prepend(this.el); + }, + + /** + * Bind any necessary function callbacks to DOM events (click, + * mousemove, etc.). + */ + bindHandlers: function() { + this.el.on({ + click: this.onClick + }); + this.state.el.on({ + ready: this.autoPlay, + ended: this.autoAdvance, + destroy: this.destroy + }); + }, + + onClick: function(event) { + var enabled = !this.state.auto_advance; + event.preventDefault(); + this.setAutoAdvance(enabled); + this.el.trigger('autoadvancechange', [enabled]); + }, + + /** + * Sets or unsets auto advance. + * @param {boolean} enabled Sets auto advance. + */ + setAutoAdvance: function(enabled) { + if (enabled) { + this.el.addClass('active'); + } else { + this.el.removeClass('active'); + } + }, + + autoPlay: function() { + // 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. + var isFirstComponent = this.state.el.parents('.vert-0').length === 1; + if (this.state.auto_advance && isFirstComponent) { + this.state.videoCommands.execute('play'); + } + }, + + autoAdvance: function() { + if (this.state.auto_advance) { + $('.sequence-nav-button.button-next').first().click(); + } + } + }; + + return AutoAdvanceControl; + }); +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js b/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js index ab8fbfccbf..0fc90de6c2 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js @@ -16,8 +16,8 @@ } _.bindAll(this, 'onReady', 'onPlay', 'onPause', 'onEnded', 'onSeek', - 'onSpeedChange', 'onShowLanguageMenu', 'onHideLanguageMenu', 'onSkip', - 'onShowTranscript', 'onHideTranscript', 'onShowCaptions', 'onHideCaptions', + 'onSpeedChange', 'onAutoAdvanceChange', 'onShowLanguageMenu', 'onHideLanguageMenu', + 'onSkip', 'onShowTranscript', 'onHideTranscript', 'onShowCaptions', 'onHideCaptions', 'destroy'); this.state = state; @@ -45,6 +45,7 @@ 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, @@ -105,6 +106,12 @@ }); }, + onAutoAdvanceChange: function(event, enabled) { + this.log('auto_advance_change_video', { + enabled: enabled + }); + }, + onShowLanguageMenu: function() { this.log('edx.video.language_menu.shown'); }, diff --git a/common/lib/xmodule/xmodule/js/src/video/09_save_state_plugin.js b/common/lib/xmodule/xmodule/js/src/video/09_save_state_plugin.js index 8210c60ec7..7657d37a2a 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_save_state_plugin.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_save_state_plugin.js @@ -1,6 +1,6 @@ (function(define) { 'use strict'; - define('video/09_save_state_plugin.js', [], function() { + define('video/09_save_state_plugin.js', ['underscore'], function(_) { /** * Save state module. * @exports video/09_save_state_plugin.js @@ -15,8 +15,8 @@ return new SaveStatePlugin(state, i18n, options); } - _.bindAll(this, 'onSpeedChange', 'saveStateHandler', 'bindUnloadHandler', 'onUnload', 'onYoutubeAvailability', - 'onLanguageChange', 'destroy'); + _.bindAll(this, 'onSpeedChange', 'onAutoAdvanceChange', 'saveStateHandler', 'bindUnloadHandler', 'onUnload', + 'onYoutubeAvailability', 'onLanguageChange', 'destroy'); this.state = state; this.options = _.extend({events: []}, options); this.state.videoSaveStatePlugin = this; @@ -38,6 +38,7 @@ initialize: function() { this.events = { speedchange: this.onSpeedChange, + autoadvancechange: this.onAutoAdvanceChange, play: this.bindUnloadHandler, 'pause destroy': this.saveStateHandler, 'language_menu:change': this.onLanguageChange, @@ -71,6 +72,11 @@ this.state.storage.setItem('general_speed', newSpeed); }, + onAutoAdvanceChange: function(event, enabled) { + this.saveState(true, {auto_advance: enabled}); + this.state.storage.setItem('auto_advance', enabled); + }, + saveStateHandler: function() { this.saveState(true); }, diff --git a/common/lib/xmodule/xmodule/js/src/video/10_main.js b/common/lib/xmodule/xmodule/js/src/video/10_main.js index 90fd45aea3..93b5890d9c 100644 --- a/common/lib/xmodule/xmodule/js/src/video/10_main.js +++ b/common/lib/xmodule/xmodule/js/src/video/10_main.js @@ -45,6 +45,7 @@ 'video/06_video_progress_slider.js', 'video/07_video_volume_control.js', 'video/08_video_speed_control.js', + 'video/08_video_auto_advance_control.js', 'video/09_video_caption.js', 'video/09_play_placeholder.js', 'video/09_play_pause_control.js', @@ -61,9 +62,9 @@ ], function( VideoStorage, initialize, FocusGrabber, VideoAccessibleMenu, VideoControl, VideoFullScreen, - VideoQualityControl, VideoProgressSlider, VideoVolumeControl, VideoSpeedControl, VideoCaption, - VideoPlayPlaceholder, VideoPlayPauseControl, VideoPlaySkipControl, VideoSkipControl, VideoBumper, - VideoSaveStatePlugin, VideoEventsPlugin, VideoEventsBumperPlugin, VideoPoster, + VideoQualityControl, VideoProgressSlider, VideoVolumeControl, VideoSpeedControl, VideoAutoAdvanceControl, + VideoCaption, VideoPlayPlaceholder, VideoPlayPauseControl, VideoPlaySkipControl, VideoSkipControl, + VideoBumper, VideoSaveStatePlugin, VideoEventsPlugin, VideoEventsBumperPlugin, VideoPoster, VideoCompletionHandler, VideoCommands, VideoContextMenu ) { var youtubeXhr = null, @@ -74,10 +75,13 @@ id = el.attr('id').replace(/video_/, ''), storage = VideoStorage('VideoState', id), bumperMetadata = el.data('bumper-metadata'), - mainVideoModules = [FocusGrabber, VideoControl, VideoPlayPlaceholder, - VideoPlayPauseControl, VideoProgressSlider, VideoSpeedControl, VideoVolumeControl, - VideoQualityControl, VideoFullScreen, VideoCaption, VideoCommands, VideoContextMenu, - VideoSaveStatePlugin, VideoEventsPlugin, VideoCompletionHandler], + autoAdvanceEnabled = el.data('autoadvance-enabled') === 'True', + mainVideoModules = [ + FocusGrabber, VideoControl, VideoPlayPlaceholder, + VideoPlayPauseControl, VideoProgressSlider, VideoSpeedControl, + VideoVolumeControl, VideoQualityControl, VideoFullScreen, VideoCaption, VideoCommands, + VideoContextMenu, VideoSaveStatePlugin, VideoEventsPlugin, VideoCompletionHandler + ].concat(autoAdvanceEnabled ? [VideoAutoAdvanceControl] : []), bumperVideoModules = [VideoControl, VideoPlaySkipControl, VideoSkipControl, VideoVolumeControl, VideoCaption, VideoCommands, VideoSaveStatePlugin, VideoEventsBumperPlugin, VideoCompletionHandler], diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index 156fe89f0c..07ad649eef 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -172,6 +172,14 @@ class InheritanceMixin(XBlockMixin): default=True, scope=Scope.settings ) + video_auto_advance = Boolean( + display_name=_("Enable video auto-advance"), + help=_( + "Specify whether to show an auto-advance button in videos. If the student clicks it, when the last video in a unit finishes it will automatically move to the next unit and autoplay the first video." + ), + scope=Scope.settings, + default=False + ) video_bumper = Dict( display_name=_("Video Pre-Roll"), help=_( diff --git a/common/lib/xmodule/xmodule/video_module/video_handlers.py b/common/lib/xmodule/xmodule/video_module/video_handlers.py index d36729d4a1..e0a5e6af4a 100644 --- a/common/lib/xmodule/xmodule/video_module/video_handlers.py +++ b/common/lib/xmodule/xmodule/video_module/video_handlers.py @@ -51,13 +51,14 @@ class VideoStudentViewHandlers(object): Update values of xfields, that were changed by student. """ accepted_keys = [ - 'speed', 'saved_video_position', 'transcript_language', + 'speed', 'auto_advance', 'saved_video_position', 'transcript_language', 'transcript_download_format', 'youtube_is_available', 'bumper_last_view_date', 'bumper_do_not_show_again' ] conversions = { 'speed': json.loads, + 'auto_advance': json.loads, 'saved_video_position': RelativeTime.isotime_to_timedelta, 'youtube_is_available': json.loads, } diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py index 5646ff54dd..b4a34f9d98 100644 --- a/common/lib/xmodule/xmodule/video_module/video_module.py +++ b/common/lib/xmodule/xmodule/video_module/video_module.py @@ -144,6 +144,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, resource_string(module, 'js/src/video/06_video_progress_slider.js'), resource_string(module, 'js/src/video/07_video_volume_control.js'), resource_string(module, 'js/src/video/08_video_speed_control.js'), + resource_string(module, 'js/src/video/08_video_auto_advance_control.js'), resource_string(module, 'js/src/video/09_video_caption.js'), resource_string(module, 'js/src/video/09_play_placeholder.js'), resource_string(module, 'js/src/video/09_play_pause_control.js'), @@ -338,6 +339,19 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, else: completion_enabled = False + # This is the setting that controls whether the autoadvance button will be visible, not whether the + # video will autoadvance or not. + # For autoadvance controls to be shown, both the feature flag and the course setting must be true. + # This allows to enable the feature for certain courses only. + autoadvance_enabled = settings.FEATURES.get('ENABLE_AUTOADVANCE_VIDEOS', False) and \ + getattr(self, 'video_auto_advance', False) + + # This is the current status of auto-advance (not the control visibility). + # But when controls aren't visible we force it to off. The student might have once set the preference to + # true, but now staff or admin have hidden the autoadvance button and the student won't be able to disable + # it anymore; therefore we force-disable it in this case (when controls aren't visible). + autoadvance_this_video = self.auto_advance and autoadvance_enabled + metadata = { 'saveStateUrl': self.system.ajax_url + '/save_user_state', 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False), @@ -353,6 +367,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, 'showCaptions': json.dumps(self.show_captions), 'generalSpeed': self.global_speed, 'speed': self.speed, + 'autoAdvance': autoadvance_this_video, 'savedVideoPosition': self.saved_video_position.total_seconds(), 'start': self.start_time.total_seconds(), 'end': self.end_time.total_seconds(), @@ -395,6 +410,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, bumperize(self) context = { + 'autoadvance_enabled': autoadvance_enabled, 'bumper_metadata': json.dumps(self.bumper['metadata']), # pylint: disable=E1101 'metadata': json.dumps(OrderedDict(metadata)), 'poster': json.dumps(get_poster(self)), diff --git a/common/lib/xmodule/xmodule/video_module/video_xfields.py b/common/lib/xmodule/xmodule/video_module/video_xfields.py index cec0036391..9dbc249871 100644 --- a/common/lib/xmodule/xmodule/video_module/video_xfields.py +++ b/common/lib/xmodule/xmodule/video_module/video_xfields.py @@ -148,6 +148,15 @@ class VideoFields(object): scope=Scope.preferences, default=1.0 ) + auto_advance = Boolean( + help=_("Specify whether to advance automatically to the next unit when the video ends."), + scope=Scope.preferences, + # The default is True because this field only has an effect when auto-advance controls are enabled + # (globally enabled through feature flag and locally enabled through course setting); in that case + # it's good to start auto-advancing and let the student disable it, instead of the other way around + # (requiring the user to enable it). When auto-advance controls are hidden, this field won't be used. + default=True, + ) youtube_is_available = Boolean( help=_("Specify whether YouTube is available for the user."), scope=Scope.user_info, diff --git a/common/test/acceptance/pages/studio/settings_advanced.py b/common/test/acceptance/pages/studio/settings_advanced.py index 769adae1bb..febd176360 100644 --- a/common/test/acceptance/pages/studio/settings_advanced.py +++ b/common/test/acceptance/pages/studio/settings_advanced.py @@ -178,6 +178,7 @@ class AdvancedSettingsPage(CoursePage): 'course_image', 'banner_image', 'video_thumbnail_image', + 'video_auto_advance', 'cosmetic_display_price', 'advertised_start', 'announcement', diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py index 203350d7ef..f5789c52e3 100644 --- a/lms/djangoapps/courseware/tests/test_video_mongo.py +++ b/lms/djangoapps/courseware/tests/test_video_mongo.py @@ -54,6 +54,7 @@ class TestVideoYouTube(TestVideo): sources = [u'example.mp4', u'example.webm'] expected_context = { + 'autoadvance_enabled': False, 'branding_info': None, 'license': None, 'bumper_metadata': 'null', @@ -64,6 +65,7 @@ class TestVideoYouTube(TestVideo): 'handout': None, 'id': self.item_descriptor.location.html_id(), 'metadata': json.dumps(OrderedDict({ + 'autoAdvance': False, 'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'autoplay': False, 'streams': '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg', @@ -134,6 +136,7 @@ class TestVideoNonYouTube(TestVideo): sources = [u'example.mp4', u'example.webm'] expected_context = { + 'autoadvance_enabled': False, 'branding_info': None, 'license': None, 'bumper_metadata': 'null', @@ -144,6 +147,7 @@ class TestVideoNonYouTube(TestVideo): 'handout': None, 'id': self.item_descriptor.location.html_id(), 'metadata': json.dumps(OrderedDict({ + 'autoAdvance': False, 'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'autoplay': False, 'streams': '1.00:3_yD_cEKoCk', @@ -201,6 +205,7 @@ class TestGetHtmlMethod(BaseTestXmodule): super(TestGetHtmlMethod, self).setUp() self.setup_course() self.default_metadata_dict = OrderedDict({ + 'autoAdvance': False, 'saveStateUrl': '', 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'streams': '1.00:3_yD_cEKoCk', @@ -293,6 +298,7 @@ class TestGetHtmlMethod(BaseTestXmodule): sources = [u'example.mp4', u'example.webm'] expected_context = { + 'autoadvance_enabled': False, 'branding_info': None, 'license': None, 'bumper_metadata': 'null', @@ -411,6 +417,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ] initial_context = { + 'autoadvance_enabled': False, 'branding_info': None, 'license': None, 'bumper_metadata': 'null', @@ -533,6 +540,7 @@ class TestGetHtmlMethod(BaseTestXmodule): metadata['autoplay'] = False metadata['sources'] = "" initial_context = { + 'autoadvance_enabled': False, 'branding_info': None, 'license': None, 'bumper_metadata': 'null', @@ -704,6 +712,7 @@ class TestGetHtmlMethod(BaseTestXmodule): metadata = self.default_metadata_dict metadata['sources'] = "" initial_context = { + 'autoadvance_enabled': False, 'branding_info': None, 'license': None, 'bumper_metadata': 'null', @@ -810,6 +819,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ] initial_context = { + 'autoadvance_enabled': False, 'branding_info': { 'logo_src': 'http://www.xuetangx.com/static/images/logo.png', 'logo_tag': 'Video hosted by XuetangX.com', @@ -1705,7 +1715,8 @@ class TestVideoWithBumper(TestVideo): """ CATEGORY = "video" METADATA = {} - FEATURES = settings.FEATURES + # Use temporary FEATURES in this test without affecting the original + FEATURES = dict(settings.FEATURES) @patch('xmodule.video_module.bumper_utils.get_bumper_settings') def test_is_bumper_enabled(self, get_bumper_settings): @@ -1753,6 +1764,7 @@ class TestVideoWithBumper(TestVideo): content = self.item_descriptor.render(STUDENT_VIEW).content sources = [u'example.mp4', u'example.webm'] expected_context = { + 'autoadvance_enabled': False, 'branding_info': None, 'license': None, 'bumper_metadata': json.dumps(OrderedDict({ @@ -1779,6 +1791,7 @@ class TestVideoWithBumper(TestVideo): 'handout': None, 'id': self.item_descriptor.location.html_id(), 'metadata': json.dumps(OrderedDict({ + 'autoAdvance': False, 'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'autoplay': False, 'streams': '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg', @@ -1821,3 +1834,131 @@ class TestVideoWithBumper(TestVideo): expected_content = self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context) self.assertEqual(content, expected_content) + + +@ddt.ddt +class TestAutoAdvanceVideo(TestVideo): + """ + Tests the server side of video auto-advance. + """ + CATEGORY = "video" + METADATA = {} + # Use temporary FEATURES in this test without affecting the original + FEATURES = dict(settings.FEATURES) + + def prepare_expected_context(self, autoadvanceenabled_flag, autoadvance_flag): + """ + Build a dictionary with data expected by some operations in this test. + Only parameters related to auto-advance are variable, rest is fixed. + """ + context = { + 'autoadvance_enabled': autoadvanceenabled_flag, + 'branding_info': None, + 'license': None, + 'cdn_eval': False, + 'cdn_exp_group': None, + 'display_name': u'A Name', + 'download_video_link': u'example.mp4', + 'handout': None, + 'id': self.item_descriptor.location.html_id(), + 'bumper_metadata': 'null', + 'metadata': json.dumps(OrderedDict({ + 'autoAdvance': autoadvance_flag, + 'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', + 'autoplay': False, + 'streams': '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg', + 'sub': 'a_sub_file.srt.sjson', + 'sources': [u'example.mp4', u'example.webm'], + 'duration': None, + 'poster': None, + 'captionDataDir': None, + 'showCaptions': 'true', + 'generalSpeed': 1.0, + 'speed': None, + 'savedVideoPosition': 0.0, + 'start': 3603.0, + 'end': 3610.0, + 'transcriptLanguage': 'en', + 'transcriptLanguages': OrderedDict({'en': 'English', 'uk': u'Українська'}), + 'ytTestTimeout': 1500, + 'ytApiUrl': 'https://www.youtube.com/iframe_api', + 'ytMetadataUrl': 'https://www.googleapis.com/youtube/v3/videos/', + 'ytKey': None, + 'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url( + self.item_descriptor, 'transcript', 'translation/__lang__' + ).rstrip('/?'), + 'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url( + self.item_descriptor, 'transcript', 'available_translations' + ).rstrip('/?'), + 'autohideHtml5': False, + 'recordedYoutubeIsAvailable': True, + 'completionEnabled': False, + 'completionPercentage': 0.95, + 'publishCompletionUrl': self.get_handler_url('publish_completion', ''), + })), + 'track': None, + 'transcript_download_format': u'srt', + 'transcript_download_formats_list': [ + {'display_name': 'SubRip (.srt) file', 'value': 'srt'}, + {'display_name': 'Text (.txt) file', 'value': 'txt'} + ], + 'poster': 'null' + } + return context + + def assert_content_matches_expectations(self, autoadvanceenabled_must_be, autoadvance_must_be): + """ + Check (assert) that loading video.html produces content that corresponds + to the passed context. + Helper function to avoid code repetition. + """ + + with override_settings(FEATURES=self.FEATURES): + content = self.item_descriptor.render(STUDENT_VIEW).content + + expected_context = self.prepare_expected_context( + autoadvanceenabled_flag=autoadvanceenabled_must_be, + autoadvance_flag=autoadvance_must_be, + ) + + with override_settings(FEATURES=self.FEATURES): + expected_content = self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context) + + self.assertEqual(content, expected_content) + + def change_course_setting_autoadvance(self, new_value): + """ + Change the .video_auto_advance course setting (a.k.a. advanced setting). + This avoids doing .save(), and instead modifies the instance directly. + Based on test code for video_bumper setting. + """ + # This first render is done to initialize the instance + self.item_descriptor.render(STUDENT_VIEW) + item_instance = self.item_descriptor.xmodule_runtime.xmodule_instance + item_instance.video_auto_advance = new_value + # After this step, render() should see the new value + # e.g. use self.item_descriptor.render(STUDENT_VIEW).content + + @ddt.data( + (False, False), + (False, True), + (True, False), + (True, True), + ) + @ddt.unpack + def test_is_autoadvance_available_and_enabled(self, global_setting, course_setting): + """ + Check that the autoadvance is not available when it is disabled via feature flag + (ENABLE_AUTOADVANCE_VIDEOS set to False) or by the course setting. + It checks that: + - only when the feature flag and the course setting are True (at the same time) + the controls are visible + - in that case (when the controls are visible) the video will autoadvance + (because that's the default), in other cases it won't + """ + self.FEATURES.update({"ENABLE_AUTOADVANCE_VIDEOS": global_setting}) + self.change_course_setting_autoadvance(new_value=course_setting) + self.assert_content_matches_expectations( + autoadvanceenabled_must_be=(global_setting and course_setting), + autoadvance_must_be=(global_setting and course_setting), + ) diff --git a/lms/envs/common.py b/lms/envs/common.py index b9b03331eb..7fdf8c0ad0 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -163,6 +163,10 @@ FEATURES = { # Don't autoplay videos for students 'AUTOPLAY_VIDEOS': False, + # Move the student to next page when a video finishes. Set to True to show + # an auto-advance button in videos. If False, videos never auto-advance. + 'ENABLE_AUTOADVANCE_VIDEOS': False, + # Enable instructor dash to submit background tasks 'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True, diff --git a/lms/templates/video.html b/lms/templates/video.html index 4cebc37f9a..d475106cab 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -13,6 +13,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string class="video closed" data-metadata='${metadata}' data-bumper-metadata='${bumper_metadata}' + data-autoadvance-enabled="${autoadvance_enabled}" data-poster='${poster}' tabindex="-1" >