videojs-contrib-dash
Version:
A Video.js source-handler providing MPEG-DASH playback.
442 lines (360 loc) • 13.9 kB
JavaScript
import window from 'global/window';
import videojs from 'video.js';
import dashjs from 'dashjs';
import setupAudioTracks from './setup-audio-tracks';
import setupTextTracks from './setup-text-tracks';
import document from 'global/document';
import './ttml-text-track-display';
/**
* videojs-contrib-dash
*
* Use Dash.js to playback DASH content inside of Video.js via a SourceHandler
*/
class Html5DashJS {
constructor(source, tech, options) {
// Get options from tech if not provided for backwards compatibility
options = options || tech.options_;
this.player = videojs(options.playerId);
this.player.dash = this.player.dash || {};
this.tech_ = tech;
this.el_ = tech.el();
this.elParent_ = this.el_.parentNode;
this.hasFiniteDuration_ = false;
// Do nothing if the src is falsey
if (!source.src) {
return;
}
// While the manifest is loading and Dash.js has not finished initializing
// we must defer events and functions calls with isReady_ and then `triggerReady`
// again later once everything is setup
tech.isReady_ = false;
if (Html5DashJS.updateSourceData) {
videojs.log.warn('updateSourceData has been deprecated.' +
' Please switch to using hook("updatesource", callback).');
source = Html5DashJS.updateSourceData(source);
}
// call updatesource hooks
Html5DashJS.hooks('updatesource').forEach((hook) => {
source = hook(source);
});
const manifestSource = source.src;
this.keySystemOptions_ = Html5DashJS.buildDashJSProtData(source.keySystemOptions);
this.player.dash.mediaPlayer = dashjs.MediaPlayer().create();
this.mediaPlayer_ = this.player.dash.mediaPlayer;
// Log MedaPlayer messages through video.js
if (Html5DashJS.useVideoJSDebug) {
videojs.log.warn('useVideoJSDebug has been deprecated.' +
' Please switch to using hook("beforeinitialize", callback).');
Html5DashJS.useVideoJSDebug(this.mediaPlayer_);
}
if (Html5DashJS.beforeInitialize) {
videojs.log.warn('beforeInitialize has been deprecated.' +
' Please switch to using hook("beforeinitialize", callback).');
Html5DashJS.beforeInitialize(this.player, this.mediaPlayer_);
}
Html5DashJS.hooks('beforeinitialize').forEach((hook) => {
hook(this.player, this.mediaPlayer_);
});
// Must run controller before these two lines or else there is no
// element to bind to.
this.mediaPlayer_.initialize();
// Retrigger a dash.js-specific error event as a player error
// See src/streaming/utils/ErrorHandler.js in dash.js code
// Handled with error (playback is stopped):
// - capabilityError
// - downloadError
// - manifestError
// - mediaSourceError
// - mediaKeySessionError
// Not handled:
// - timedTextError (video can still play)
// - mediaKeyMessageError (only fires under 'might not work' circumstances)
this.retriggerError_ = (event) => {
if (event.error === 'capability' && event.event === 'mediasource') {
// No support for MSE
this.player.error({
code: 4,
message: 'The media cannot be played because it requires a feature ' +
'that your browser does not support.'
});
} else if (event.error === 'manifestError' && (
// Manifest type not supported
(event.event.id === 'createParser') ||
// Codec(s) not supported
(event.event.id === 'codec') ||
// No streams available to stream
(event.event.id === 'nostreams') ||
// Error creating Stream object
(event.event.id === 'nostreamscomposed') ||
// syntax error parsing the manifest
(event.event.id === 'parse') ||
// a stream has multiplexed audio+video
(event.event.id === 'multiplexedrep')
)) {
// These errors have useful error messages, so we forward it on
this.player.error({code: 4, message: event.event.message});
} else if (event.error === 'mediasource') {
// This error happens when dash.js fails to allocate a SourceBuffer
// OR the underlying video element throws a `MediaError`.
// If it's a buffer allocation fail, the message states which buffer
// (audio/video/text) failed allocation.
// If it's a `MediaError`, dash.js inspects the error object for
// additional information to append to the error type.
if (event.event.match('MEDIA_ERR_ABORTED')) {
this.player.error({code: 1, message: event.event});
} else if (event.event.match('MEDIA_ERR_NETWORK')) {
this.player.error({code: 2, message: event.event});
} else if (event.event.match('MEDIA_ERR_DECODE')) {
this.player.error({code: 3, message: event.event});
} else if (event.event.match('MEDIA_ERR_SRC_NOT_SUPPORTED')) {
this.player.error({code: 4, message: event.event});
} else if (event.event.match('MEDIA_ERR_ENCRYPTED')) {
this.player.error({code: 5, message: event.event});
} else if (event.event.match('UNKNOWN')) {
// We shouldn't ever end up here, since this would mean a
// `MediaError` thrown by the video element that doesn't comply
// with the W3C spec. But, since we should handle the error,
// throwing a MEDIA_ERR_SRC_NOT_SUPPORTED is probably the
// most reasonable thing to do.
this.player.error({code: 4, message: event.event});
} else {
// Buffer allocation error
this.player.error({code: 4, message: event.event});
}
} else if (event.error === 'capability' && event.event === 'encryptedmedia') {
// Browser doesn't support EME
this.player.error({
code: 5,
message: 'The media cannot be played because it requires encryption ' +
'features that your browser does not support.'
});
} else if (event.error === 'key_session') {
// This block handles pretty much all errors thrown by the
// encryption subsystem
this.player.error({
code: 5,
message: event.event
});
} else if (event.error === 'download') {
this.player.error({
code: 2,
message: 'The media playback was aborted because too many consecutive ' +
'download errors occurred.'
});
} else if (event.error === 'mssError') {
this.player.error({
code: 3,
message: event.event
});
} else {
// ignore the error
return;
}
// only reset the dash player in 10ms async, so that the rest of the
// calling function finishes
setTimeout(() => {
this.mediaPlayer_.reset();
}, 10);
};
this.mediaPlayer_.on(dashjs.MediaPlayer.events.ERROR, this.retriggerError_);
this.getDuration_ = (event) => {
const periods = event.data.Period_asArray;
const oldHasFiniteDuration = this.hasFiniteDuration_;
if (event.data.mediaPresentationDuration || periods[periods.length - 1].duration) {
this.hasFiniteDuration_ = true;
} else {
// in case we run into a weird situation where we're VOD but then
// switch to live
this.hasFiniteDuration_ = false;
}
if (this.hasFiniteDuration_ !== oldHasFiniteDuration) {
this.player.trigger('durationchange');
}
};
this.mediaPlayer_.on(dashjs.MediaPlayer.events.MANIFEST_LOADED, this.getDuration_);
// Apply all dash options that are set
if (options.dash) {
Object.keys(options.dash).forEach((key) => {
if (key === 'useTTML') {
return;
}
const dashOptionsKey = 'set' + key.charAt(0).toUpperCase() + key.slice(1);
let value = options.dash[key];
if (this.mediaPlayer_.hasOwnProperty(dashOptionsKey)) {
// Providing a key without `set` prefix is now deprecated.
videojs.log.warn('Using dash options in videojs-contrib-dash without the set prefix ' +
`has been deprecated. Change '${key}' to '${dashOptionsKey}'`);
// Set key so it will still work
key = dashOptionsKey;
}
if (!this.mediaPlayer_.hasOwnProperty(key)) {
videojs.log.warn(
`Warning: dash configuration option unrecognized: ${key}`
);
return;
}
// Guarantee `value` is an array
if (!Array.isArray(value)) {
value = [value];
}
this.mediaPlayer_[key](...value);
});
}
this.mediaPlayer_.attachView(this.el_);
if (options.dash && options.dash.useTTML) {
this.ttmlContainer_ = this.player.addChild('TTMLTextTrackDisplay');
this.mediaPlayer_.attachTTMLRenderingDiv(this.ttmlContainer_.el());
}
// Dash.js autoplays by default, video.js will handle autoplay
this.mediaPlayer_.setAutoPlay(false);
// Setup audio tracks
setupAudioTracks.call(null, this.player, tech);
// Setup text tracks
setupTextTracks.call(null, this.player, tech, options);
// Attach the source with any protection data
this.mediaPlayer_.setProtectionData(this.keySystemOptions_);
this.mediaPlayer_.attachSource(manifestSource);
this.tech_.triggerReady();
}
/*
* Iterate over the `keySystemOptions` array and convert each object into
* the type of object Dash.js expects in the `protData` argument.
*
* Also rename 'licenseUrl' property in the options to an 'serverURL' property
*/
static buildDashJSProtData(keySystemOptions) {
const output = {};
if (!keySystemOptions || !Array.isArray(keySystemOptions)) {
return null;
}
for (let i = 0; i < keySystemOptions.length; i++) {
const keySystem = keySystemOptions[i];
const options = videojs.mergeOptions({}, keySystem.options);
if (options.licenseUrl) {
options.serverURL = options.licenseUrl;
delete options.licenseUrl;
}
output[keySystem.name] = options;
}
return output;
}
dispose() {
if (this.mediaPlayer_) {
this.mediaPlayer_.off(dashjs.MediaPlayer.events.ERROR, this.retriggerError_);
this.mediaPlayer_.off(dashjs.MediaPlayer.events.MANIFEST_LOADED, this.getDuration_);
this.mediaPlayer_.reset();
}
if (this.player.dash) {
delete this.player.dash;
}
if (this.ttmlContainer_) {
this.ttmlContainer_.dispose();
this.player.removeChild('TTMLTextTrackDisplay');
}
}
duration() {
if (this.mediaPlayer_.isDynamic() && !this.hasFiniteDuration_) {
return Infinity;
}
return this.mediaPlayer_.duration();
}
/**
* Get a list of hooks for a specific lifecycle
*
* @param {string} type the lifecycle to get hooks from
* @param {Function|Function[]} [hook] Optionally add a hook tothe lifecycle
* @return {Array} an array of hooks or epty if none
* @method hooks
*/
static hooks(type, hook) {
Html5DashJS.hooks_[type] = Html5DashJS.hooks_[type] || [];
if (hook) {
Html5DashJS.hooks_[type] = Html5DashJS.hooks_[type].concat(hook);
}
return Html5DashJS.hooks_[type];
}
/**
* Add a function hook to a specific dash lifecycle
*
* @param {string} type the lifecycle to hook the function to
* @param {Function|Function[]} hook the function or array of functions to attach
* @method hook
*/
static hook(type, hook) {
Html5DashJS.hooks(type, hook);
}
/**
* Remove a hook from a specific dash lifecycle.
*
* @param {string} type the lifecycle that the function hooked to
* @param {Function} hook The hooked function to remove
* @return {boolean} True if the function was removed, false if not found
* @method removeHook
*/
static removeHook(type, hook) {
const index = Html5DashJS.hooks(type).indexOf(hook);
if (index === -1) {
return false;
}
Html5DashJS.hooks_[type] = Html5DashJS.hooks_[type].slice();
Html5DashJS.hooks_[type].splice(index, 1);
return true;
}
}
Html5DashJS.hooks_ = {};
const canHandleKeySystems = function(source) {
// copy the source
source = JSON.parse(JSON.stringify(source));
if (Html5DashJS.updateSourceData) {
videojs.log.warn('updateSourceData has been deprecated.' +
' Please switch to using hook("updatesource", callback).');
source = Html5DashJS.updateSourceData(source);
}
// call updatesource hooks
Html5DashJS.hooks('updatesource').forEach((hook) => {
source = hook(source);
});
const videoEl = document.createElement('video');
if (source.keySystemOptions &&
!(window.navigator.requestMediaKeySystemAccess ||
// IE11 Win 8.1
videoEl.msSetMediaKeys)) {
return false;
}
return true;
};
videojs.DashSourceHandler = function() {
return {
canHandleSource(source) {
const dashExtRE = /\.mpd/i;
if (!canHandleKeySystems(source)) {
return '';
}
if (videojs.DashSourceHandler.canPlayType(source.type)) {
return 'probably';
} else if (dashExtRE.test(source.src)) {
return 'maybe';
}
return '';
},
handleSource(source, tech, options) {
return new Html5DashJS(source, tech, options);
},
canPlayType(type) {
return videojs.DashSourceHandler.canPlayType(type);
}
};
};
videojs.DashSourceHandler.canPlayType = function(type) {
const dashTypeRE = /^application\/dash\+xml/i;
if (dashTypeRE.test(type)) {
return 'probably';
}
return '';
};
// Only add the SourceHandler if the browser supports MediaSourceExtensions
if (window.MediaSource) {
videojs.getTech('Html5').registerSourceHandler(videojs.DashSourceHandler(), 0);
}
videojs.Html5DashJS = Html5DashJS;
export default Html5DashJS;