Adds a course option to auto-advance videos.

If enabled for a course, as soon as the video ends, the next unit or subsection
will be loaded, and if it contains a single video, that video will be played.

Course authors can enable the setting for a course, but learners can toggle the
setting on or off once it's enabled on the course.
This commit is contained in:
Daniel Clemente
2017-07-06 22:54:24 -06:00
committed by Jillian Vogel
parent 98fd05b4ee
commit ecf01d1b52
22 changed files with 556 additions and 14 deletions

View File

@@ -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,

View File

@@ -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 {

View File

@@ -0,0 +1,35 @@
<!-- Based on video.html -->
<div class="course-content">
<div id="video_example">
<div id="example">
<div
id="video_id"
class="video closed"
data-autoadvance-enabled="True"
data-metadata='{"autoAdvance": "true", "autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": [], "speed": "1.5", "startTime": "", "streams": "0.5:7tqY6eQzVhE,1.0:cogebirgzzM,1.5:abcdefghijkl", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "/base/fixtures/youtube_iframe_api.js", "ytImageUrl": "", "ytTestTimeout": "1500", "ytMetadataUrl": "www.googleapis.com/youtube/v3/videos/"}'
>
<div class="focus_grabber first"></div>
<div class="tc-wrapper">
<article class="video-wrapper">
<span tabindex="0" class="spinner" aria-hidden="false" aria-label="${_('Loading video player')}"></span>
<span tabindex="-1" class="btn-play fa fa-youtube-play fa-2x is-hidden" aria-hidden="true" aria-label="${_('Play video')}"></span>
<div class="video-player-pre"></div>
<section class="video-player">
<iframe id="id"></iframe>
</section>
<div class="video-player-post"></div>
<section class="video-controls is-hidden">
<div class="slider"></div>
<div>
<div class="vcr"><div class="vidtime">0:00 / 0:00</div></div>
<div class="secondary-controls"></div>
</div>
</section>
</article>
</div>
<div class="focus_grabber last"></div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,35 @@
<!-- Based on video.html -->
<div class="course-content">
<div id="video_example">
<div id="example">
<div
id="video_id"
class="video closed"
data-autoadvance-enabled="True"
data-metadata='{"autoAdvance": "false", "autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": [], "speed": "1.5", "startTime": "", "streams": "0.5:7tqY6eQzVhE,1.0:cogebirgzzM,1.5:abcdefghijkl", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "/base/fixtures/youtube_iframe_api.js", "ytImageUrl": "", "ytTestTimeout": "1500", "ytMetadataUrl": "www.googleapis.com/youtube/v3/videos/"}'
>
<div class="focus_grabber first"></div>
<div class="tc-wrapper">
<article class="video-wrapper">
<span tabindex="0" class="spinner" aria-hidden="false" aria-label="${_('Loading video player')}"></span>
<span tabindex="-1" class="btn-play fa fa-youtube-play fa-2x is-hidden" aria-hidden="true" aria-label="${_('Play video')}"></span>
<div class="video-player-pre"></div>
<section class="video-player">
<iframe id="id"></iframe>
</section>
<div class="video-player-post"></div>
<section class="video-controls is-hidden">
<div class="slider"></div>
<div>
<div class="vcr"><div class="vidtime">0:00 / 0:00</div></div>
<div class="secondary-controls"></div>
</div>
</section>
</article>
</div>
<div class="focus_grabber last"></div>
</div>
</div>
</div>
</div>

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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'),

View File

@@ -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'] || '';

View File

@@ -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);
});

View File

@@ -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([
'<button class="control auto-advance" aria-disabled="false" title="',
'{autoAdvanceText}',
'">',
'<span class="label" aria-hidden="true">',
'{autoAdvanceText}',
'</span>',
'</button>'].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));

View File

@@ -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');
},

View File

@@ -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);
},

View File

@@ -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],

View File

@@ -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=_(

View File

@@ -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,
}

View File

@@ -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)),

View File

@@ -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,

View File

@@ -178,6 +178,7 @@ class AdvancedSettingsPage(CoursePage):
'course_image',
'banner_image',
'video_thumbnail_image',
'video_auto_advance',
'cosmetic_display_price',
'advertised_start',
'announcement',

View File

@@ -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),
)

View File

@@ -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,

View File

@@ -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"
>