/* 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 (!force_report && 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)); }, }); var RememberVolumePlugin = videojs.extend(Plugin, { constructor: function(player, options) { Plugin.call(this, player, options); player.on('volumechange', this.on_volumechange.bind(this)); this.restore_volume(); }, restore_volume: function() { let volume_str = localStorage.getItem('video-player-volume'); if (volume_str == null) return; this.player.volume(1.0 * volume_str); }, on_volumechange: function(event) { localStorage.setItem('video-player-volume', this.player.volume()); }, }); // Register our watch-progress-bookkeeping plugin. videojs.registerPlugin('progressPlugin', VideoProgressPlugin); videojs.registerPlugin('rememberVolumePlugin', RememberVolumePlugin);