Saving & restoring video watching progress

Video progress updates:

- Mark as 'done' when 90% or more is watched.
- Keep 'done' flag when re-watching.

The video progress is stored on three events, whichever comes first:

- Every 30 seconds of video.
- Every 10% of the video.
- Every pause/stop/navigation to another page.
- When we detect the video is looping.
This commit is contained in:
2018-08-30 18:30:38 +02:00
parent 3f8e0396cf
commit 2698be3e12
7 changed files with 590 additions and 4 deletions

View File

@@ -0,0 +1,182 @@
/* Video.JS plugin for keeping track of user's viewing progress.
Also registers the analytics plugin.
Progress is reported after a number of seconds or a percentage
of the duration of the video, whichever comes first.
Example usage:
videojs(videoPlayerElement, options).ready(function() {
let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}';
this.progressPlugin({'report_url': report_url});
});
*/
// Report after progressing this many seconds video-time.
let PROGRESS_REPORT_INTERVAL_SEC = 30;
// Report after progressing this percentage of the entire video (scale 0-100).
let PROGRESS_REPORT_INTERVAL_PERC = 10;
// Don't report within this many milliseconds of wall-clock time of the previous report.
let PROGRESS_RELAXING_TIME_MSEC = 500;
var Plugin = videojs.getPlugin('plugin');
var VideoProgressPlugin = videojs.extend(Plugin, {
constructor: function(player, options) {
Plugin.call(this, player, options);
this.last_wallclock_time_ms = 0;
this.last_inspected_progress_in_sec = 0;
this.last_reported_progress_in_sec = 0;
this.last_reported_progress_in_perc = 0;
this.report_url = options.report_url;
this.fetch_progress_url = options.fetch_progress_url;
this.reported_error = false;
this.reported_looping = false;
if (typeof this.report_url === 'undefined' || !this.report_url) {
/* If we can't report anything, don't bother registering event handlers. */
videojs.log('VideoProgressPlugin: no report_url option given. Not storing video progress.');
} else {
/* Those events will have 'this' bound to the player,
* which is why we explicitly re-bind to 'this''. */
player.on('timeupdate', this.on_timeupdate.bind(this));
player.on('pause', this.on_pause.bind(this));
}
if (typeof this.fetch_progress_url === 'undefined' || !this.fetch_progress_url) {
/* If we can't report anything, don't bother registering event handlers. */
videojs.log('VideoProgressPlugin: no fetch_progress_url option given. Not restoring video progress.');
} else {
this.resume_playback();
}
},
resume_playback: function() {
let on_done = function(progress, status, xhr) {
/* 'progress' is an object like:
{"progress_in_sec": 3,
"progress_in_percent": 51,
"last_watched": "Fri, 31 Aug 2018 13:53:06 GMT",
"done": true}
*/
switch (xhr.status) {
case 204: return; // no info found.
case 200:
/* Don't do anything when the progress is at 100%.
* Moving the current time to the end makes no sense then. */
if (progress.progress_in_percent >= 100) return;
/* Set the 'last reported' props before manipulating the
* player, so that the manipulation doesn't trigger more
* API calls to remember what we just restored. */
this.last_reported_progress_in_sec = progress.progress_in_sec;
this.last_reported_progress_in_perc = progress.progress_in_perc;
console.log("Continuing playback at ", progress.progress_in_percent, "% from", progress.last_watched);
this.player.currentTime(progress.progress_in_sec);
this.player.play();
return;
default:
console.log("Unknown code", xhr.status, "getting video progress information.");
}
};
$.get(this.fetch_progress_url)
.fail(function(error) {
console.log("Unable to fetch video progress information:", xhrErrorResponseMessage(error));
})
.done(on_done.bind(this));
},
/* Pausing playback should report the progress.
* This function is also called when playback stops at the end of the video,
* so it's important to report in this case; otherwise progress will never
* reach 100%. */
on_pause: function(event) {
this.inspect_progress(true);
},
on_timeupdate: function() {
this.inspect_progress(false);
},
inspect_progress: function(force_report) {
// Don't report seeking when paused, only report actual playback.
if (this.player.paused()) return;
let now_in_ms = new Date().getTime();
if (!force_report && now_in_ms - this.last_wallclock_time_ms < PROGRESS_RELAXING_TIME_MSEC) {
// We're trying too fast, don't bother doing any other calculation.
// console.log('skipping, already reported', now_in_ms - this.last_wallclock_time_ms, 'ms ago.');
return;
}
let progress_in_sec = this.player.currentTime();
let duration_in_sec = this.player.duration();
/* Instead of reporting the current time, report reaching the end
* of the video. This ensures that it's properly marked as 'done'. */
if (!this.reported_looping) {
let margin = 1.25 * PROGRESS_RELAXING_TIME_MSEC / 1000.0;
let is_looping = progress_in_sec == 0 && duration_in_sec - this.last_inspected_progress_in_sec < margin;
this.last_inspected_progress_in_sec = progress_in_sec;
if (is_looping) {
this.reported_looping = true;
this.report(this.player.duration(), 100, now_in_ms);
return;
}
}
if (Math.abs(progress_in_sec - this.last_reported_progress_in_sec) < 0.01) {
// Already reported this, don't bother doing it again.
return;
}
let progress_in_perc = 100 * progress_in_sec / duration_in_sec;
let diff_sec = progress_in_sec - this.last_reported_progress_in_sec;
let diff_perc = progress_in_perc - this.last_reported_progress_in_perc;
if (!force_report
&& Math.abs(diff_perc) < PROGRESS_REPORT_INTERVAL_PERC
&& Math.abs(diff_sec) < PROGRESS_REPORT_INTERVAL_SEC) {
return;
}
this.report(progress_in_sec, progress_in_perc, now_in_ms);
},
report: function(progress_in_sec, progress_in_perc, now_in_ms) {
/* Store when we tried, not when we succeeded. This function can be
* called every 15-250 milliseconds, so we don't want to retry with
* that frequency. */
this.last_wallclock_time_ms = now_in_ms;
let on_fail = function(error) {
/* Don't show (as in: a toastr popup) the error to the user,
* as it doesn't impact their ability to play the video.
* Also show the error only once, instead of spamming. */
if (this.reported_error) return;
let msg = xhrErrorResponseMessage(error);
console.log('Unable to report viewing progress:', msg);
this.reported_error = true;
};
let on_done = function() {
this.last_reported_progress_in_sec = progress_in_sec;
this.last_reported_progress_in_perc = progress_in_perc;
};
$.post(this.report_url, {
progress_in_sec: progress_in_sec,
progress_in_perc: Math.round(progress_in_perc),
})
.fail(on_fail.bind(this))
.done(on_done.bind(this));
},
});
// Register our watch-progress-bookkeeping plugin.
videojs.registerPlugin('progressPlugin', VideoProgressPlugin);

View File

@@ -67,6 +67,14 @@ script(type="text/javascript").
});
this.hotkeys();
{% if current_user.is_authenticated %}
let fetch_progress_url = '{{ url_for("users_api.get_video_progress", video_id=node._id) }}';
let report_url = '{{ url_for("users_api.set_video_progress", video_id=node._id) }}';
this.progressPlugin({
'report_url': report_url,
'fetch_progress_url': fetch_progress_url,
});
{% endif %}
});
function addVideoPlayerButton(data) {

View File

@@ -74,6 +74,7 @@ link(rel="amphtml", href="{{ url_for('nodes.view', node_id=node._id, _external=T
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-6.2.8.min.js') }}")
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-ga-0.4.2.min.js') }}")
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/videojs-hotkeys-0.2.20.min.js') }}")
script(src="{{ url_for('static_pillar', filename='assets/js/video_plugins.min.js') }}")
| {% endblock %}
| {% block css %}