Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cms/envs/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,15 @@
# Allow extra middleware classes to be added to the app through configuration.
MIDDLEWARE_CLASSES.extend(ENV_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', []))

########################## Settings for Completion API #####################

# Once a user has watched this percentage of a video, mark it as complete:
# (0.0 = 0%, 1.0 = 100%)
COMPLETION_VIDEO_COMPLETE_PERCENTAGE = ENV_TOKENS.get(
'COMPLETION_VIDEO_COMPLETE_PERCENTAGE',
COMPLETION_VIDEO_COMPLETE_PERCENTAGE,
)

########################## Derive Any Derived Settings #######################

derive_settings(__name__)
7 changes: 7 additions & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -1493,3 +1493,10 @@
ZENDESK_API_KEY = None
ZENDESK_OAUTH_ACCESS_TOKEN = None
ZENDESK_CUSTOM_FIELDS = {}


############## Settings for Completion API #########################

# Once a user has watched this percentage of a video, mark it as complete:
# (0.0 = 0%, 1.0 = 100%)
COMPLETION_VIDEO_COMPLETE_PERCENTAGE = 0.95
60 changes: 60 additions & 0 deletions common/lib/xmodule/xmodule/js/spec/video/completion_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
(function() {
'use strict';
describe('VideoPlayer completion', function() {
var state, oldOTBD;

beforeEach(function() {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice')
.and.returnValue(null);

state = jasmine.initializePlayer({
recordedYoutubeIsAvailable: true,
completionEnabled: true,
publishCompletionUrl: 'https://example.com/publish_completion_url'

});
state.completionHandler.completeAfterTime = 20;
});

afterEach(function() {
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
if (state.videoPlayer) {
state.videoPlayer.destroy();
}
});

it('calls the completion api when marking an object complete', function() {
state.completionHandler.markCompletion(Date.now());
expect($.ajax).toHaveBeenCalledWith({
url: state.config.publishCompletionUrl,
type: 'POST',
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({completion: 1.0}),
success: jasmine.any(Function),
error: jasmine.any(Function)
});
expect(state.completionHandler.isComplete).toEqual(true);
});

it('calls the completion api on the LMS when the time updates', function() {
spyOn(state.completionHandler, 'markCompletion').and.callThrough();
state.el.trigger('timeupdate', 24.0);
expect(state.completionHandler.markCompletion).toHaveBeenCalled();
state.completionHandler.markCompletion.calls.reset();
// But the handler is not called again after the block is completed.
state.el.trigger('timeupdate', 30.0);
expect(state.completionHandler.markCompletion).not.toHaveBeenCalled();
});

it('calls the completion api on the LMS when the video ends', function() {
spyOn(state.completionHandler, 'markCompletion').and.callThrough();
state.el.trigger('ended');
expect(state.completionHandler.markCompletion).toHaveBeenCalled();
});
});
}).call(this);
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@
}).done(done);
});

it('set new inccorrect values', function() {
it('set new incorrect values', function() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test seems to be flaky. I saw it fail once, and couldn't bear to let the typo survive. I'm not sure what the process is for external contributors marking tests flaky, so I haven't created any tickets about it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you know it to be flaky, I don't see any issue with you following the updated flaky test process.

var seek = state.videoPlayer.player.video.currentTime;
state.videoPlayer.player.seekTo(-50);
expect(state.videoPlayer.player.getCurrentTime()).toBe(seek);
Expand Down
178 changes: 178 additions & 0 deletions common/lib/xmodule/xmodule/js/src/video/09_completion.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
(function(define) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does thumbs-up mean this is coming?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I worked on tests on Friday, but wasn't able to get them all in place. I'll ping you again when they are ready or by end of day at the latest.

'use strict';
/**
* Completion handler
* @exports video/09_completion.js
* @constructor
* @param {Object} state The object containing the state of the video
* @return {jquery Promise}
*/
define('video/09_completion.js', [], function() {
var VideoCompletionHandler = function(state) {
if (!(this instanceof VideoCompletionHandler)) {
return new VideoCompletionHandler(state);
}
this.state = state;
this.state.completionHandler = this;
this.initialize();
return $.Deferred().resolve().promise();
};

VideoCompletionHandler.prototype = {

/** Tears down the VideoCompletionHandler.
*
* * Removes backreferences from this.state to this.
* * Turns off signal handlers.
*/
destroy: function() {
this.el.remove();
this.el.off('timeupdate.completion');
this.el.off('ended.completion');
delete this.state.completionHandler;
},

/** Initializes the VideoCompletionHandler.
*
* This sets all the instance variables needed to perform
* completion calculations.
*/
initialize: function() {
// Attributes with "Time" in the name refer to the number of seconds since
// the beginning of the video, except for lastSentTime, which refers to a
// timestamp in seconds since the Unix epoch.
this.lastSentTime = undefined;
this.isComplete = false;
this.completionPercentage = this.state.config.completionPercentage;
this.startTime = this.state.config.startTime;
this.endTime = this.state.config.endTime;
this.isEnabled = this.state.config.completionEnabled;
if (this.endTime) {
this.completeAfterTime = this.calculateCompleteAfterTime(this.startTime, this.endTime);
}
if (this.isEnabled) {
this.bindHandlers();
}
},

/** Bind event handler callbacks.
*
* When ended is triggered, mark the video complete
* unconditionally.
*
* When timeupdate is triggered, check to see if the user has
* passed the completeAfterTime in the video, and if so, mark the
* video complete.
*
* When destroy is triggered, clean up outstanding resources.
*/
bindHandlers: function() {
var self = this;

/** Event handler to check if the video is complete, and submit
* a completion if it is.
*
* If the timeupdate handler doesn't fire after the required
* percentage, this will catch any fully complete videos.
*/
this.state.el.on('ended.completion', function() {
self.handleEnded();
});

/** Event handler to check video progress, and mark complete if
* greater than completionPercentage
*/
this.state.el.on('timeupdate.completion', function(ev, currentTime) {
self.handleTimeUpdate(currentTime);
});

/** Event handler to clean up resources when the video player
* is destroyed.
*/
this.state.el.off('destroy', this.destroy);
},

/** Handler to call when the ended event is triggered */
handleEnded: function() {
if (this.isComplete) {
return;
}
this.markCompletion();
},

/** Handler to call when a timeupdate event is triggered */
handleTimeUpdate: function(currentTime) {
var duration;
if (this.isComplete) {
return;
}
if (this.lastSentTime !== undefined && currentTime - this.lastSentTime < this.repostDelaySeconds()) {
// Throttle attempts to submit in case of network issues
return;
}
if (this.completeAfterTime === undefined) {
// Duration is not available at initialization time
duration = this.state.videoPlayer.duration();
if (!duration) {
// duration is not yet set. Wait for another event,
// or fall back to 'ended' handler.
return;
}
this.completeAfterTime = this.calculateCompleteAfterTime(this.startTime, duration);
}

if (currentTime > this.completeAfterTime) {
this.markCompletion(currentTime);
}
},

/** Submit completion to the LMS */
markCompletion: function(currentTime) {
var self = this;
var errmsg;
this.isComplete = true;
this.lastSentTime = currentTime;
if (this.state.config.publishCompletionUrl) {
$.ajax({
type: 'POST',
url: this.state.config.publishCompletionUrl,
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({completion: 1.0}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jcdyer I messed around with a couple of the jquery.ajax options for posting data, like processData: false and different options for contentType, but had no luck getting the data parsed properly by the XBlock.json_handler.

Also found this stackoverflow post which suggests stringifying the data like you did.

So no worries here :)

success: function() {
self.state.el.off('timeupdate.completion');
self.state.el.off('ended.completion');
},
error: function(xhr) {
/* eslint-disable no-console */
self.complete = false;
errmsg = 'Failed to submit completion';
if (xhr.responseJSON !== undefined) {
errmsg += ': ' + xhr.responseJSON.error;
}
console.warn(errmsg);
/* eslint-enable no-console */
}
});
} else {
/* eslint-disable no-console */
console.warn('publishCompletionUrl not defined');
/* eslint-enable no-console */
}
},

/** Determine what point in the video (in seconds from the
* beginning) counts as complete.
*/
calculateCompleteAfterTime: function(startTime, endTime) {
return startTime + (endTime - startTime) * this.completionPercentage;
},

/** How many seconds to wait after a POST fails to try again. */
repostDelaySeconds: function() {
return 3.0;
}
};
return VideoCompletionHandler;
});
}(RequireJS.define));
25 changes: 14 additions & 11 deletions common/lib/xmodule/xmodule/js/src/video/10_main.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* globals _ */
(function(require, $) {
'use strict';
// In the case when the Video constructor will be called before RequireJS finishes loading all of the Video
Expand All @@ -15,9 +16,9 @@
// If mock function was called with second parameter set to truthy value, we invoke the real `window.Video`
// on all the stored elements so far.
if (processTempCallStack) {
$.each(tempCallStack, function(index, element) {
$.each(tempCallStack, function(index, el) {
// By now, `window.Video` is the real constructor.
window.Video(element);
window.Video(el);
});

return;
Expand Down Expand Up @@ -54,15 +55,16 @@
'video/09_events_plugin.js',
'video/09_events_bumper_plugin.js',
'video/09_poster.js',
'video/09_completion.js',
'video/10_commands.js',
'video/095_video_context_menu.js'
],
function(
VideoStorage, initialize, FocusGrabber, VideoAccessibleMenu, VideoControl, VideoFullScreen,
VideoQualityControl, VideoProgressSlider, VideoVolumeControl, VideoSpeedControl, VideoCaption,
VideoPlayPlaceholder, VideoPlayPauseControl, VideoPlaySkipControl, VideoSkipControl, VideoBumper,
VideoSaveStatePlugin, VideoEventsPlugin, VideoEventsBumperPlugin, VideoPoster, VideoCommands,
VideoContextMenu
VideoSaveStatePlugin, VideoEventsPlugin, VideoEventsBumperPlugin, VideoPoster,
VideoCompletionHandler, VideoCommands, VideoContextMenu
) {
var youtubeXhr = null,
oldVideo = window.Video;
Expand All @@ -75,9 +77,10 @@
mainVideoModules = [FocusGrabber, VideoControl, VideoPlayPlaceholder,
VideoPlayPauseControl, VideoProgressSlider, VideoSpeedControl, VideoVolumeControl,
VideoQualityControl, VideoFullScreen, VideoCaption, VideoCommands, VideoContextMenu,
VideoSaveStatePlugin, VideoEventsPlugin],
VideoSaveStatePlugin, VideoEventsPlugin, VideoCompletionHandler],
bumperVideoModules = [VideoControl, VideoPlaySkipControl, VideoSkipControl,
VideoVolumeControl, VideoCaption, VideoCommands, VideoSaveStatePlugin, VideoEventsBumperPlugin],
VideoVolumeControl, VideoCaption, VideoCommands, VideoSaveStatePlugin,
VideoEventsBumperPlugin, VideoCompletionHandler],
state = {
el: el,
id: id,
Expand All @@ -104,10 +107,10 @@
return bumperState;
};

var player = function(state) {
var player = function(innerState) {
return function() {
_.extend(state.metadata, {autoplay: true, focusFirstControl: true});
initialize(state, element);
_.extend(innerState.metadata, {autoplay: true, focusFirstControl: true});
initialize(innerState, element);
};
};

Expand All @@ -120,8 +123,8 @@
new VideoPoster(el, {
poster: el.data('poster'),
onClick: _.once(function() {
var mainVideoPlayer = player(state),
bumper, bumperState;
var mainVideoPlayer = player(state);
var bumper, bumperState;
if (storage.getItem('isBumperShown')) {
mainVideoPlayer();
} else {
Expand Down
7 changes: 5 additions & 2 deletions common/lib/xmodule/xmodule/video_module/bumper_utils.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"""
Utils for video bumper
"""
from collections import OrderedDict
import copy
import json
import pytz
import logging
from collections import OrderedDict

from datetime import datetime, timedelta
from django.conf import settings
import pytz

from .video_utils import set_query_parameter

Expand Down Expand Up @@ -137,6 +137,9 @@ def bumper_metadata(video, sources):
'transcriptAvailableTranslationsUrl': set_query_parameter(
video.runtime.handler_url(video, 'transcript', 'available_translations').rstrip('/?'), 'is_bumper', 1
),
'publishCompletionUrl': set_query_parameter(
video.runtime.handler_url(video, 'publish_completion', '').rstrip('?'), 'is_bumper', 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if the difference is intentional and needed, but just noting the difference between rstrip('/?') and rstrip('?') (i.e. dropped slash). Also, reminder that this call is duplicated depending on if any change is required.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. I tested both ways, and noticed that the trailing / wasn't actually changing the behavior of the call, so I figured why worry about it?

),
})

return metadata
Loading