391 lines
16 KiB
JavaScript
391 lines
16 KiB
JavaScript
/* eslint-disable no-console, no-param-reassign */
|
|
/**
|
|
* @file HTML5 video player module. Provides methods to control the in-browser
|
|
* HTML5 video player.
|
|
*
|
|
* The goal was to write this module so that it closely resembles the YouTube
|
|
* API. The main reason for this is because initially the edX video player
|
|
* supported only YouTube videos. When HTML5 support was added, for greater
|
|
* compatibility, and to reduce the amount of code that needed to be modified,
|
|
* it was decided to write a similar API as the one provided by YouTube.
|
|
*
|
|
* @external RequireJS
|
|
*
|
|
* @module HTML5Video
|
|
*/
|
|
|
|
(function(requirejs, require, define) {
|
|
define(
|
|
'video/02_html5_video.js',
|
|
['underscore'],
|
|
function(_) {
|
|
var HTML5Video = {};
|
|
|
|
HTML5Video.Player = (function() {
|
|
/*
|
|
* Constructor function for HTML5 Video player.
|
|
*
|
|
* @param {String|Object} el A DOM element where the HTML5 player will
|
|
* be inserted (as returned by jQuery(selector) function), or a
|
|
* selector string which will be used to select an element. This is a
|
|
* required parameter.
|
|
*
|
|
* @param config - An object whose properties will be used as
|
|
* configuration options for the HTML5 video player. This is an
|
|
* optional parameter. In the case if this parameter is missing, or
|
|
* some of the config object's properties are missing, defaults will be
|
|
* used. The available options (and their defaults) are as
|
|
* follows:
|
|
*
|
|
* config = {
|
|
*
|
|
* videoSources: [], // An array with properties being video
|
|
* // sources. The property name is the
|
|
* // video format of the source. Supported
|
|
* // video formats are: 'mp4', 'webm', and
|
|
* // 'ogg'.
|
|
* poster: Video poster URL
|
|
*
|
|
* browserIsSafari: Flag to tell if current browser is Safari
|
|
*
|
|
* events: { // Object's properties identify the
|
|
* // events that the API fires, and the
|
|
* // functions (event listeners) that the
|
|
* // API will call when those events occur.
|
|
* // If value is null, or property is not
|
|
* // specified, then no callback will be
|
|
* // called for that event.
|
|
*
|
|
* onReady: null,
|
|
* onStateChange: null
|
|
* }
|
|
* }
|
|
*/
|
|
function Player(el, config) {
|
|
var errorMessage, lastSource, sourceList;
|
|
|
|
// Create HTML markup for individual sources of the HTML5 <video> element.
|
|
sourceList = $.map(config.videoSources, function(source) {
|
|
return [
|
|
'<source ',
|
|
'src="', source,
|
|
// Following hack allows to open the same video twice
|
|
// https://code.google.com/p/chromium/issues/detail?id=31014
|
|
// Check whether the url already has a '?' inside, and if so,
|
|
// use '&' instead of '?' to prevent breaking the url's integrity.
|
|
(source.indexOf('?') === -1 ? '?' : '&'),
|
|
(new Date()).getTime(), '" />'
|
|
].join('');
|
|
});
|
|
|
|
// do common initialization independent of player type
|
|
this.init(el, config);
|
|
|
|
// Create HTML markup for the <video> element, populating it with
|
|
// sources from previous step. Set playback not supported error message.
|
|
errorMessage = [
|
|
gettext('This browser cannot play .mp4, .ogg, or .webm files.'),
|
|
gettext('Try using a different browser, such as Google Chrome.')
|
|
].join('');
|
|
this.video.innerHTML = sourceList.join('') + errorMessage;
|
|
|
|
lastSource = this.videoEl.find('source').last();
|
|
lastSource.on('error', this.showErrorMessage.bind(this));
|
|
lastSource.on('error', this.onError.bind(this));
|
|
this.videoEl.on('error', this.onError.bind(this));
|
|
}
|
|
|
|
Player.prototype.showPlayButton = function() {
|
|
this.videoOverlayEl.removeClass('is-hidden');
|
|
};
|
|
|
|
Player.prototype.hidePlayButton = function() {
|
|
this.videoOverlayEl.addClass('is-hidden');
|
|
};
|
|
|
|
Player.prototype.showLoading = function() {
|
|
this.el
|
|
.removeClass('is-initialized')
|
|
.find('.spinner')
|
|
.removeAttr('tabindex')
|
|
.attr({'aria-hidden': 'false'});
|
|
};
|
|
|
|
Player.prototype.hideLoading = function() {
|
|
this.el
|
|
.addClass('is-initialized')
|
|
.find('.spinner')
|
|
.attr({'aria-hidden': 'false', tabindex: -1});
|
|
};
|
|
|
|
Player.prototype.updatePlayerLoadingState = function(state) {
|
|
if (state === 'show') {
|
|
this.hidePlayButton();
|
|
this.showLoading();
|
|
} else if (state === 'hide') {
|
|
this.hideLoading();
|
|
}
|
|
};
|
|
|
|
Player.prototype.callStateChangeCallback = function() {
|
|
if ($.isFunction(this.config.events.onStateChange)) {
|
|
this.config.events.onStateChange({
|
|
data: this.playerState
|
|
});
|
|
}
|
|
};
|
|
|
|
Player.prototype.pauseVideo = function() {
|
|
this.video.pause();
|
|
};
|
|
|
|
Player.prototype.seekTo = function(value) {
|
|
if (
|
|
typeof value === 'number'
|
|
&& value <= this.video.duration
|
|
&& value >= 0
|
|
) {
|
|
this.video.currentTime = value;
|
|
}
|
|
};
|
|
|
|
Player.prototype.setVolume = function(value) {
|
|
if (typeof value === 'number' && value <= 100 && value >= 0) {
|
|
this.video.volume = value * 0.01;
|
|
}
|
|
};
|
|
|
|
Player.prototype.getCurrentTime = function() {
|
|
return this.video.currentTime;
|
|
};
|
|
|
|
Player.prototype.playVideo = function() {
|
|
this.video.play();
|
|
};
|
|
|
|
Player.prototype.getPlayerState = function() {
|
|
return this.playerState;
|
|
};
|
|
|
|
Player.prototype.getVolume = function() {
|
|
return this.video.volume;
|
|
};
|
|
|
|
Player.prototype.getDuration = function() {
|
|
if (isNaN(this.video.duration)) {
|
|
return 0;
|
|
}
|
|
|
|
return this.video.duration;
|
|
};
|
|
|
|
Player.prototype.setPlaybackRate = function(value) {
|
|
var newSpeed;
|
|
|
|
newSpeed = parseFloat(value);
|
|
|
|
if (isFinite(newSpeed)) {
|
|
if (this.video.playbackRate !== value) {
|
|
this.video.playbackRate = value;
|
|
}
|
|
}
|
|
};
|
|
|
|
Player.prototype.getAvailablePlaybackRates = function() {
|
|
return [0.75, 1.0, 1.25, 1.5, 2.0];
|
|
};
|
|
|
|
// eslint-disable-next-line no-underscore-dangle
|
|
Player.prototype._getLogs = function() {
|
|
return this.logs;
|
|
};
|
|
|
|
Player.prototype.showErrorMessage = function(event, css) {
|
|
var cssSelecter = css || '.video-player .video-error';
|
|
this.el
|
|
.find('.video-player div')
|
|
.addClass('hidden')
|
|
.end()
|
|
.find(cssSelecter)
|
|
.removeClass('is-hidden')
|
|
.end()
|
|
.addClass('is-initialized')
|
|
.find('.spinner')
|
|
.attr({
|
|
'aria-hidden': 'true',
|
|
tabindex: -1
|
|
});
|
|
};
|
|
|
|
Player.prototype.onError = function() {
|
|
if ($.isFunction(this.config.events.onError)) {
|
|
this.config.events.onError();
|
|
}
|
|
};
|
|
|
|
Player.prototype.destroy = function() {
|
|
this.video.removeEventListener('loadedmetadata', this.onLoadedMetadata, false);
|
|
this.video.removeEventListener('play', this.onPlay, false);
|
|
this.video.removeEventListener('playing', this.onPlaying, false);
|
|
this.video.removeEventListener('pause', this.onPause, false);
|
|
this.video.removeEventListener('ended', this.onEnded, false);
|
|
this.el
|
|
.find('.video-player div')
|
|
.removeClass('is-hidden')
|
|
.end()
|
|
.find('.video-player .video-error')
|
|
.addClass('is-hidden')
|
|
.end()
|
|
.removeClass('is-initialized')
|
|
.find('.spinner')
|
|
.attr({'aria-hidden': 'false'});
|
|
this.videoEl.off('remove');
|
|
this.videoEl.remove();
|
|
};
|
|
|
|
Player.prototype.onReady = function() {
|
|
this.config.events.onReady(null);
|
|
this.showPlayButton();
|
|
};
|
|
|
|
Player.prototype.onLoadedMetadata = function() {
|
|
this.playerState = HTML5Video.PlayerState.PAUSED;
|
|
if ($.isFunction(this.config.events.onReady)) {
|
|
this.onReady();
|
|
}
|
|
};
|
|
|
|
Player.prototype.onPlay = function() {
|
|
this.playerState = HTML5Video.PlayerState.BUFFERING;
|
|
this.callStateChangeCallback();
|
|
this.videoOverlayEl.addClass('is-hidden');
|
|
};
|
|
|
|
Player.prototype.onPlaying = function() {
|
|
this.playerState = HTML5Video.PlayerState.PLAYING;
|
|
this.callStateChangeCallback();
|
|
this.videoOverlayEl.addClass('is-hidden');
|
|
};
|
|
|
|
Player.prototype.onPause = function() {
|
|
this.playerState = HTML5Video.PlayerState.PAUSED;
|
|
this.callStateChangeCallback();
|
|
this.showPlayButton();
|
|
};
|
|
|
|
Player.prototype.onEnded = function() {
|
|
this.playerState = HTML5Video.PlayerState.ENDED;
|
|
this.callStateChangeCallback();
|
|
};
|
|
|
|
Player.prototype.init = function(el, config) {
|
|
var isTouch = window.onTouchBasedDevice() || '',
|
|
events = ['loadstart', 'progress', 'suspend', 'abort', 'error',
|
|
'emptied', 'stalled', 'play', 'pause', 'loadedmetadata',
|
|
'loadeddata', 'waiting', 'playing', 'canplay', 'canplaythrough',
|
|
'seeking', 'seeked', 'timeupdate', 'ended', 'ratechange',
|
|
'durationchange', 'volumechange'
|
|
],
|
|
self = this,
|
|
callback;
|
|
|
|
this.config = config;
|
|
this.logs = [];
|
|
this.el = $(el);
|
|
|
|
// Because of problems with creating video element via jquery
|
|
// (http://bugs.jquery.com/ticket/9174) we create it using native JS.
|
|
this.video = document.createElement('video');
|
|
|
|
// Get the jQuery object and set error event handlers
|
|
this.videoEl = $(this.video);
|
|
|
|
// Video player overlay play button
|
|
this.videoOverlayEl = this.el.find('.video-wrapper .btn-play');
|
|
|
|
// The player state is used by other parts of the VideoPlayer to
|
|
// determine what the video is currently doing.
|
|
this.playerState = HTML5Video.PlayerState.UNSTARTED;
|
|
|
|
_.bindAll(this, 'onLoadedMetadata', 'onPlay', 'onPlaying', 'onPause', 'onEnded');
|
|
|
|
// Attach a 'click' event on the <video> element. It will cause the
|
|
// video to pause/play.
|
|
callback = function() {
|
|
var PlayerState = HTML5Video.PlayerState;
|
|
|
|
if (self.playerState === PlayerState.PLAYING) {
|
|
self.playerState = PlayerState.PAUSED;
|
|
self.pauseVideo();
|
|
} else {
|
|
self.playerState = PlayerState.PLAYING;
|
|
self.playVideo();
|
|
}
|
|
};
|
|
this.videoEl.on('click', callback);
|
|
this.videoOverlayEl.on('click', callback);
|
|
|
|
this.debug = false;
|
|
$.each(events, function(index, eventName) {
|
|
self.video.addEventListener(eventName, function() {
|
|
self.logs.push({
|
|
'event name': eventName,
|
|
state: self.playerState
|
|
});
|
|
|
|
if (self.debug) {
|
|
console.log(
|
|
'event name:', eventName,
|
|
'state:', self.playerState,
|
|
'readyState:', self.video.readyState,
|
|
'networkState:', self.video.networkState
|
|
);
|
|
}
|
|
|
|
el.trigger('html5:' + eventName, arguments);
|
|
});
|
|
});
|
|
|
|
// When the <video> tag has been processed by the browser, and it
|
|
// is ready for playback, notify other parts of the VideoPlayer,
|
|
// and initially pause the video.
|
|
this.video.addEventListener('loadedmetadata', this.onLoadedMetadata, false);
|
|
this.video.addEventListener('play', this.onPlay, false);
|
|
this.video.addEventListener('playing', this.onPlaying, false);
|
|
this.video.addEventListener('pause', this.onPause, false);
|
|
this.video.addEventListener('ended', this.onEnded, false);
|
|
|
|
if (/iP(hone|od)/i.test(isTouch[0])) {
|
|
this.videoEl.prop('controls', true);
|
|
}
|
|
|
|
// Set video poster
|
|
if (this.config.poster) {
|
|
this.videoEl.prop('poster', this.config.poster);
|
|
}
|
|
|
|
// Place the <video> element on the page.
|
|
this.videoEl.appendTo(el.find('.video-player > div:first-child'));
|
|
};
|
|
|
|
return Player;
|
|
}());
|
|
|
|
// The YouTube API presents several constants which describe the player's
|
|
// state at a given moment. HTML5Video API will copy these constants so
|
|
// that code which uses both the YouTube API and this API doesn't have to
|
|
// change.
|
|
HTML5Video.PlayerState = {
|
|
UNSTARTED: -1,
|
|
ENDED: 0,
|
|
PLAYING: 1,
|
|
PAUSED: 2,
|
|
BUFFERING: 3,
|
|
CUED: 5
|
|
};
|
|
|
|
// HTML5Video object - what this module exports.
|
|
return HTML5Video;
|
|
});
|
|
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
|