videojs-contrib-hls
Version:
Play back HLS with video.js, even where it's not natively supported
614 lines (537 loc) • 19 kB
JavaScript
/**
* @file videojs-contrib-hls.js
*
* The main file for the HLS project.
* License: https://github.com/videojs/videojs-contrib-hls/blob/master/LICENSE
*/
import document from 'global/document';
import PlaylistLoader from './playlist-loader';
import Playlist from './playlist';
import xhrFactory from './xhr';
import {Decrypter, AsyncStream, decrypt} from 'aes-decrypter';
import utils from './bin-utils';
import {MediaSource, URL} from 'videojs-contrib-media-sources';
import m3u8 from 'm3u8-parser';
import videojs from 'video.js';
import { MasterPlaylistController } from './master-playlist-controller';
import Config from './config';
import renditionSelectionMixin from './rendition-mixin';
import window from 'global/window';
import PlaybackWatcher from './playback-watcher';
import reloadSourceOnError from './reload-source-on-error';
import {
lastBandwidthSelector,
lowestBitrateCompatibleVariantSelector,
comparePlaylistBandwidth,
comparePlaylistResolution
} from './playlist-selectors.js';
const Hls = {
PlaylistLoader,
Playlist,
Decrypter,
AsyncStream,
decrypt,
utils,
STANDARD_PLAYLIST_SELECTOR: lastBandwidthSelector,
INITIAL_PLAYLIST_SELECTOR: lowestBitrateCompatibleVariantSelector,
comparePlaylistBandwidth,
comparePlaylistResolution,
xhr: xhrFactory()
};
// 0.5 MB/s
const INITIAL_BANDWIDTH = 4194304;
// Define getter/setters for config properites
[
'GOAL_BUFFER_LENGTH',
'MAX_GOAL_BUFFER_LENGTH',
'GOAL_BUFFER_LENGTH_RATE',
'BUFFER_LOW_WATER_LINE',
'MAX_BUFFER_LOW_WATER_LINE',
'BUFFER_LOW_WATER_LINE_RATE',
'BANDWIDTH_VARIANCE'
].forEach((prop) => {
Object.defineProperty(Hls, prop, {
get() {
videojs.log.warn(`using Hls.${prop} is UNSAFE be sure you know what you are doing`);
return Config[prop];
},
set(value) {
videojs.log.warn(`using Hls.${prop} is UNSAFE be sure you know what you are doing`);
if (typeof value !== 'number' || value < 0) {
videojs.log.warn(`value of Hls.${prop} must be greater than or equal to 0`);
return;
}
Config[prop] = value;
}
});
});
/**
* Updates the selectedIndex of the QualityLevelList when a mediachange happens in hls.
*
* @param {QualityLevelList} qualityLevels The QualityLevelList to update.
* @param {PlaylistLoader} playlistLoader PlaylistLoader containing the new media info.
* @function handleHlsMediaChange
*/
const handleHlsMediaChange = function(qualityLevels, playlistLoader) {
let newPlaylist = playlistLoader.media();
let selectedIndex = -1;
for (let i = 0; i < qualityLevels.length; i++) {
if (qualityLevels[i].id === newPlaylist.uri) {
selectedIndex = i;
break;
}
}
qualityLevels.selectedIndex_ = selectedIndex;
qualityLevels.trigger({
selectedIndex,
type: 'change'
});
};
/**
* Adds quality levels to list once playlist metadata is available
*
* @param {QualityLevelList} qualityLevels The QualityLevelList to attach events to.
* @param {Object} hls Hls object to listen to for media events.
* @function handleHlsLoadedMetadata
*/
const handleHlsLoadedMetadata = function(qualityLevels, hls) {
hls.representations().forEach((rep) => {
qualityLevels.addQualityLevel(rep);
});
handleHlsMediaChange(qualityLevels, hls.playlists);
};
// HLS is a source handler, not a tech. Make sure attempts to use it
// as one do not cause exceptions.
Hls.canPlaySource = function() {
return videojs.log.warn('HLS is no longer a tech. Please remove it from ' +
'your player\'s techOrder.');
};
/**
* Whether the browser has built-in HLS support.
*/
Hls.supportsNativeHls = (function() {
let video = document.createElement('video');
// native HLS is definitely not supported if HTML5 video isn't
if (!videojs.getTech('Html5').isSupported()) {
return false;
}
// HLS manifests can go by many mime-types
let canPlay = [
// Apple santioned
'application/vnd.apple.mpegurl',
// Apple sanctioned for backwards compatibility
'audio/mpegurl',
// Very common
'audio/x-mpegurl',
// Very common
'application/x-mpegurl',
// Included for completeness
'video/x-mpegurl',
'video/mpegurl',
'application/mpegurl'
];
return canPlay.some(function(canItPlay) {
return (/maybe|probably/i).test(video.canPlayType(canItPlay));
});
}());
/**
* HLS is a source handler, not a tech. Make sure attempts to use it
* as one do not cause exceptions.
*/
Hls.isSupported = function() {
return videojs.log.warn('HLS is no longer a tech. Please remove it from ' +
'your player\'s techOrder.');
};
const Component = videojs.getComponent('Component');
/**
* The Hls Handler object, where we orchestrate all of the parts
* of HLS to interact with video.js
*
* @class HlsHandler
* @extends videojs.Component
* @param {Object} source the soruce object
* @param {Tech} tech the parent tech object
* @param {Object} options optional and required options
*/
class HlsHandler extends Component {
constructor(source, tech, options) {
super(tech, options.hls);
// tech.player() is deprecated but setup a reference to HLS for
// backwards-compatibility
if (tech.options_ && tech.options_.playerId) {
let _player = videojs(tech.options_.playerId);
if (!_player.hasOwnProperty('hls')) {
Object.defineProperty(_player, 'hls', {
get: () => {
videojs.log.warn('player.hls is deprecated. Use player.tech_.hls instead.');
tech.trigger({type: 'usage', name: 'hls-player-access'});
return this;
}
});
}
}
this.tech_ = tech;
this.source_ = source;
this.stats = {};
this.ignoreNextSeekingEvent_ = false;
this.setOptions_();
// overriding native HLS only works if audio tracks have been emulated
// error early if we're misconfigured:
if (this.options_.overrideNative &&
(tech.featuresNativeVideoTracks || tech.featuresNativeAudioTracks)) {
throw new Error('Overriding native HLS requires emulated tracks. ' +
'See https://git.io/vMpjB');
}
// listen for fullscreenchange events for this player so that we
// can adjust our quality selection quickly
this.on(document, [
'fullscreenchange', 'webkitfullscreenchange',
'mozfullscreenchange', 'MSFullscreenChange'
], (event) => {
let fullscreenElement = document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement;
if (fullscreenElement && fullscreenElement.contains(this.tech_.el())) {
this.masterPlaylistController_.fastQualityChange_();
}
});
this.on(this.tech_, 'seeking', function() {
if (this.ignoreNextSeekingEvent_) {
this.ignoreNextSeekingEvent_ = false;
return;
}
this.setCurrentTime(this.tech_.currentTime());
});
this.on(this.tech_, 'error', function() {
if (this.masterPlaylistController_) {
this.masterPlaylistController_.pauseLoading();
}
});
this.on(this.tech_, 'play', this.play);
}
setOptions_() {
// defaults
this.options_.withCredentials = this.options_.withCredentials || false;
if (typeof this.options_.blacklistDuration !== 'number') {
this.options_.blacklistDuration = 5 * 60;
}
// start playlist selection at a reasonable bandwidth for
// broadband internet (0.5 MB/s) or mobile (0.0625 MB/s)
if (typeof this.options_.bandwidth !== 'number') {
this.options_.bandwidth = INITIAL_BANDWIDTH;
}
// If the bandwidth number is unchanged from the initial setting
// then this takes precedence over the enableLowInitialPlaylist option
this.options_.enableLowInitialPlaylist =
this.options_.enableLowInitialPlaylist &&
this.options_.bandwidth === INITIAL_BANDWIDTH;
// grab options passed to player.src
['withCredentials', 'bandwidth', 'handleManifestRedirects'].forEach((option) => {
if (typeof this.source_[option] !== 'undefined') {
this.options_[option] = this.source_[option];
}
});
this.bandwidth = this.options_.bandwidth;
}
/**
* called when player.src gets called, handle a new source
*
* @param {Object} src the source object to handle
*/
src(src) {
// do nothing if the src is falsey
if (!src) {
return;
}
this.setOptions_();
// add master playlist controller options
this.options_.url = this.source_.src;
this.options_.tech = this.tech_;
this.options_.externHls = Hls;
this.masterPlaylistController_ = new MasterPlaylistController(this.options_);
this.playbackWatcher_ = new PlaybackWatcher(
videojs.mergeOptions(this.options_, {
seekable: () => this.seekable()
}));
this.masterPlaylistController_.on('error', () => {
let player = videojs.players[this.tech_.options_.playerId];
player.error(this.masterPlaylistController_.error);
});
// `this` in selectPlaylist should be the HlsHandler for backwards
// compatibility with < v2
this.masterPlaylistController_.selectPlaylist =
this.selectPlaylist ?
this.selectPlaylist.bind(this) : Hls.STANDARD_PLAYLIST_SELECTOR.bind(this);
this.masterPlaylistController_.selectInitialPlaylist =
Hls.INITIAL_PLAYLIST_SELECTOR.bind(this);
// re-expose some internal objects for backwards compatibility with < v2
this.playlists = this.masterPlaylistController_.masterPlaylistLoader_;
this.mediaSource = this.masterPlaylistController_.mediaSource;
// Proxy assignment of some properties to the master playlist
// controller. Using a custom property for backwards compatibility
// with < v2
Object.defineProperties(this, {
selectPlaylist: {
get() {
return this.masterPlaylistController_.selectPlaylist;
},
set(selectPlaylist) {
this.masterPlaylistController_.selectPlaylist = selectPlaylist.bind(this);
}
},
throughput: {
get() {
return this.masterPlaylistController_.mainSegmentLoader_.throughput.rate;
},
set(throughput) {
this.masterPlaylistController_.mainSegmentLoader_.throughput.rate = throughput;
// By setting `count` to 1 the throughput value becomes the starting value
// for the cumulative average
this.masterPlaylistController_.mainSegmentLoader_.throughput.count = 1;
}
},
bandwidth: {
get() {
return this.masterPlaylistController_.mainSegmentLoader_.bandwidth;
},
set(bandwidth) {
this.masterPlaylistController_.mainSegmentLoader_.bandwidth = bandwidth;
// setting the bandwidth manually resets the throughput counter
// `count` is set to zero that current value of `rate` isn't included
// in the cumulative average
this.masterPlaylistController_.mainSegmentLoader_.throughput = {
rate: 0,
count: 0
};
}
},
/**
* `systemBandwidth` is a combination of two serial processes bit-rates. The first
* is the network bitrate provided by `bandwidth` and the second is the bitrate of
* the entire process after that - decryption, transmuxing, and appending - provided
* by `throughput`.
*
* Since the two process are serial, the overall system bandwidth is given by:
* sysBandwidth = 1 / (1 / bandwidth + 1 / throughput)
*/
systemBandwidth: {
get() {
let invBandwidth = 1 / (this.bandwidth || 1);
let invThroughput;
if (this.throughput > 0) {
invThroughput = 1 / this.throughput;
} else {
invThroughput = 0;
}
let systemBitrate = Math.floor(1 / (invBandwidth + invThroughput));
return systemBitrate;
},
set() {
videojs.log.error('The "systemBandwidth" property is read-only');
}
}
});
Object.defineProperties(this.stats, {
bandwidth: {
get: () => this.bandwidth || 0,
enumerable: true
},
mediaRequests: {
get: () => this.masterPlaylistController_.mediaRequests_() || 0,
enumerable: true
},
mediaRequestsAborted: {
get: () => this.masterPlaylistController_.mediaRequestsAborted_() || 0,
enumerable: true
},
mediaRequestsTimedout: {
get: () => this.masterPlaylistController_.mediaRequestsTimedout_() || 0,
enumerable: true
},
mediaRequestsErrored: {
get: () => this.masterPlaylistController_.mediaRequestsErrored_() || 0,
enumerable: true
},
mediaTransferDuration: {
get: () => this.masterPlaylistController_.mediaTransferDuration_() || 0,
enumerable: true
},
mediaBytesTransferred: {
get: () => this.masterPlaylistController_.mediaBytesTransferred_() || 0,
enumerable: true
},
mediaSecondsLoaded: {
get: () => this.masterPlaylistController_.mediaSecondsLoaded_() || 0,
enumerable: true
}
});
this.tech_.one('canplay',
this.masterPlaylistController_.setupFirstPlay.bind(this.masterPlaylistController_));
this.masterPlaylistController_.on('selectedinitialmedia', () => {
// Add the manual rendition mix-in to HlsHandler
renditionSelectionMixin(this);
});
// the bandwidth of the primary segment loader is our best
// estimate of overall bandwidth
this.on(this.masterPlaylistController_, 'progress', function() {
this.tech_.trigger('progress');
});
// In the live case, we need to ignore the very first `seeking` event since
// that will be the result of the seek-to-live behavior
this.on(this.masterPlaylistController_, 'firstplay', function() {
this.ignoreNextSeekingEvent_ = true;
});
this.tech_.ready(() => this.setupQualityLevels_());
// do nothing if the tech has been disposed already
// this can occur if someone sets the src in player.ready(), for instance
if (!this.tech_.el()) {
return;
}
this.tech_.src(videojs.URL.createObjectURL(
this.masterPlaylistController_.mediaSource));
}
/**
* Initializes the quality levels and sets listeners to update them.
*
* @method setupQualityLevels_
* @private
*/
setupQualityLevels_() {
let player = videojs.players[this.tech_.options_.playerId];
if (player && player.qualityLevels) {
this.qualityLevels_ = player.qualityLevels();
this.masterPlaylistController_.on('selectedinitialmedia', () => {
handleHlsLoadedMetadata(this.qualityLevels_, this);
});
this.playlists.on('mediachange', () => {
handleHlsMediaChange(this.qualityLevels_, this.playlists);
});
}
}
/**
* Begin playing the video.
*/
play() {
this.masterPlaylistController_.play();
}
/**
* a wrapper around the function in MasterPlaylistController
*/
setCurrentTime(currentTime) {
this.masterPlaylistController_.setCurrentTime(currentTime);
}
/**
* a wrapper around the function in MasterPlaylistController
*/
duration() {
return this.masterPlaylistController_.duration();
}
/**
* a wrapper around the function in MasterPlaylistController
*/
seekable() {
return this.masterPlaylistController_.seekable();
}
/**
* Abort all outstanding work and cleanup.
*/
dispose() {
if (this.playbackWatcher_) {
this.playbackWatcher_.dispose();
}
if (this.masterPlaylistController_) {
this.masterPlaylistController_.dispose();
}
if (this.qualityLevels_) {
this.qualityLevels_.dispose();
}
super.dispose();
}
}
/**
* The Source Handler object, which informs video.js what additional
* MIME types are supported and sets up playback. It is registered
* automatically to the appropriate tech based on the capabilities of
* the browser it is running in. It is not necessary to use or modify
* this object in normal usage.
*/
const HlsSourceHandler = function(mode) {
return {
canHandleSource(srcObj, options = {}) {
let localOptions = videojs.mergeOptions(videojs.options, options);
// this forces video.js to skip this tech/mode if its not the one we have been
// overriden to use, by returing that we cannot handle the source.
if (localOptions.hls &&
localOptions.hls.mode &&
localOptions.hls.mode !== mode) {
return false;
}
return HlsSourceHandler.canPlayType(srcObj.type, localOptions);
},
handleSource(source, tech, options = {}) {
let localOptions = videojs.mergeOptions(videojs.options, options, {hls: {mode}});
if (mode === 'flash') {
// We need to trigger this asynchronously to give others the chance
// to bind to the event when a source is set at player creation
tech.setTimeout(function() {
tech.trigger('loadstart');
}, 1);
}
tech.hls = new HlsHandler(source, tech, localOptions);
tech.hls.xhr = xhrFactory();
tech.hls.src(source.src);
return tech.hls;
},
canPlayType(type, options = {}) {
let localOptions = videojs.mergeOptions(videojs.options, options);
if (HlsSourceHandler.canPlayType(type, localOptions)) {
return 'maybe';
}
return '';
}
};
};
HlsSourceHandler.canPlayType = function(type, options) {
// No support for IE 10 or below
if (videojs.browser.IE_VERSION && videojs.browser.IE_VERSION <= 10) {
return false;
}
let mpegurlRE = /^(audio|video|application)\/(x-|vnd\.apple\.)?mpegurl/i;
// favor native HLS support if it's available
if (!options.hls.overrideNative && Hls.supportsNativeHls) {
return false;
}
return mpegurlRE.test(type);
};
if (typeof videojs.MediaSource === 'undefined' ||
typeof videojs.URL === 'undefined') {
videojs.MediaSource = MediaSource;
videojs.URL = URL;
}
const flashTech = videojs.getTech('Flash');
// register source handlers with the appropriate techs
if (MediaSource.supportsNativeMediaSources()) {
videojs.getTech('Html5').registerSourceHandler(HlsSourceHandler('html5'), 0);
}
if (window.Uint8Array && flashTech) {
flashTech.registerSourceHandler(HlsSourceHandler('flash'));
}
videojs.HlsHandler = HlsHandler;
videojs.HlsSourceHandler = HlsSourceHandler;
videojs.Hls = Hls;
if (!videojs.use) {
videojs.registerComponent('Hls', Hls);
}
videojs.m3u8 = m3u8;
videojs.options.hls = videojs.options.hls || {};
if (videojs.registerPlugin) {
videojs.registerPlugin('reloadSourceOnError', reloadSourceOnError);
} else {
videojs.plugin('reloadSourceOnError', reloadSourceOnError);
}
module.exports = {
Hls,
HlsHandler,
HlsSourceHandler
};