/**
* @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 GapSkipper from './gap-skipper';
import window from 'global/window';
const Hls = {
PlaylistLoader,
Playlist,
Decrypter,
AsyncStream,
decrypt,
utils,
xhr: xhrFactory()
};
Object.defineProperty(Hls, 'GOAL_BUFFER_LENGTH', {
get() {
videojs.log.warn('using Hls.GOAL_BUFFER_LENGTH is UNSAFE be sure ' +
'you know what you are doing');
return Config.GOAL_BUFFER_LENGTH;
},
set(v) {
videojs.log.warn('using Hls.GOAL_BUFFER_LENGTH is UNSAFE be sure ' +
'you know what you are doing');
if (typeof v !== 'number' || v <= 0) {
videojs.log.warn('value passed to Hls.GOAL_BUFFER_LENGTH ' +
'must be a number and greater than 0');
return;
}
Config.GOAL_BUFFER_LENGTH = v;
}
});
// A fudge factor to apply to advertised playlist bitrates to account for
// temporary flucations in client bandwidth
const BANDWIDTH_VARIANCE = 1.2;
/**
* Returns the CSS value for the specified property on an element
* using `getComputedStyle`. Firefox has a long-standing issue where
* getComputedStyle() may return null when running in an iframe with
* `display: none`.
*
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=548397
* @param {HTMLElement} el the htmlelement to work on
* @param {string} the proprety to get the style for
*/
const safeGetComputedStyle = function(el, property) {
let result;
if (!el) {
return '';
}
result = window.getComputedStyle(el);
if (!result) {
return '';
}
return result[property];
};
/**
* Chooses the appropriate media playlist based on the current
* bandwidth estimate and the player size.
*
* @return {Playlist} the highest bitrate playlist less than the currently detected
* bandwidth, accounting for some amount of bandwidth variance
*/
Hls.STANDARD_PLAYLIST_SELECTOR = function() {
let effectiveBitrate;
let sortedPlaylists = this.playlists.master.playlists.slice();
let bandwidthPlaylists = [];
let now = +new Date();
let i;
let variant;
let bandwidthBestVariant;
let resolutionPlusOne;
let resolutionPlusOneAttribute;
let resolutionBestVariant;
let width;
let height;
sortedPlaylists.sort(Hls.comparePlaylistBandwidth);
// filter out any playlists that have been excluded due to
// incompatible configurations or playback errors
sortedPlaylists = sortedPlaylists.filter((localVariant) => {
if (typeof localVariant.excludeUntil !== 'undefined') {
return now >= localVariant.excludeUntil;
}
return true;
});
// filter out any variant that has greater effective bitrate
// than the current estimated bandwidth
i = sortedPlaylists.length;
while (i--) {
variant = sortedPlaylists[i];
// ignore playlists without bandwidth information
if (!variant.attributes || !variant.attributes.BANDWIDTH) {
continue;
}
effectiveBitrate = variant.attributes.BANDWIDTH * BANDWIDTH_VARIANCE;
if (effectiveBitrate < this.bandwidth) {
bandwidthPlaylists.push(variant);
// since the playlists are sorted in ascending order by
// bandwidth, the first viable variant is the best
if (!bandwidthBestVariant) {
bandwidthBestVariant = variant;
}
}
}
i = bandwidthPlaylists.length;
// sort variants by resolution
bandwidthPlaylists.sort(Hls.comparePlaylistResolution);
// forget our old variant from above,
// or we might choose that in high-bandwidth scenarios
// (this could be the lowest bitrate rendition as we go through all of them above)
variant = null;
width = parseInt(safeGetComputedStyle(this.tech_.el(), 'width'), 10);
height = parseInt(safeGetComputedStyle(this.tech_.el(), 'height'), 10);
// iterate through the bandwidth-filtered playlists and find
// best rendition by player dimension
while (i--) {
variant = bandwidthPlaylists[i];
// ignore playlists without resolution information
if (!variant.attributes ||
!variant.attributes.RESOLUTION ||
!variant.attributes.RESOLUTION.width ||
!variant.attributes.RESOLUTION.height) {
continue;
}
// since the playlists are sorted, the first variant that has
// dimensions less than or equal to the player size is the best
let variantResolution = variant.attributes.RESOLUTION;
if (variantResolution.width === width &&
variantResolution.height === height) {
// if we have the exact resolution as the player use it
resolutionPlusOne = null;
resolutionBestVariant = variant;
break;
} else if (variantResolution.width < width &&
variantResolution.height < height) {
// if both dimensions are less than the player use the
// previous (next-largest) variant
break;
} else if (!resolutionPlusOne ||
(variantResolution.width < resolutionPlusOneAttribute.width &&
variantResolution.height < resolutionPlusOneAttribute.height)) {
// If we still haven't found a good match keep a
// reference to the previous variant for the next loop
// iteration
// By only saving variants if they are smaller than the
// previously saved variant, we ensure that we also pick
// the highest bandwidth variant that is just-larger-than
// the video player
resolutionPlusOne = variant;
resolutionPlusOneAttribute = resolutionPlusOne.attributes.RESOLUTION;
}
}
// fallback chain of variants
return resolutionPlusOne ||
resolutionBestVariant ||
bandwidthBestVariant ||
sortedPlaylists[0];
};
// 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.getComponent('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 USER_AGENT = window.navigator && window.navigator.userAgent || '';
/**
* Determines whether the browser supports a change in the audio configuration
* during playback. Currently only Firefox 48 and below do not support this.
* window.isSecureContext is a propterty that was added to window in firefox 49,
* so we can use it to detect Firefox 49+.
*
* @return {Boolean} Whether the browser supports audio config change during playback
*/
Hls.supportsAudioInfoChange_ = function() {
if (videojs.browser.IS_FIREFOX) {
let firefoxVersionMap = (/Firefox\/([\d.]+)/i).exec(USER_AGENT);
let version = parseInt(firefoxVersionMap[1], 10);
return version >= 49;
}
return true;
};
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);
// 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.');
return this;
}
});
}
}
this.tech_ = tech;
this.source_ = source;
this.stats = {};
this.ignoreNextSeekingEvent_ = false;
// handle global & Source Handler level options
this.options_ = videojs.mergeOptions(videojs.options.hls || {}, options.hls);
this.setOptions_();
// 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.audioTrackChange_ = () => {
this.masterPlaylistController_.setupAudio();
};
this.on(this.tech_, 'play', this.play);
}
setOptions_() {
// defaults
this.options_.withCredentials = this.options_.withCredentials || false;
// start playlist selection at a reasonable bandwidth for
// broadband internet
// 0.5 MB/s
if (typeof this.options_.bandwidth !== 'number') {
this.options_.bandwidth = 4194304;
}
// grab options passed to player.src
['withCredentials', 'bandwidth'].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.gapSkipper_ = new GapSkipper(this.options_);
// `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);
// 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);
}
},
bandwidth: {
get() {
return this.masterPlaylistController_.mainSegmentLoader_.bandwidth;
},
set(bandwidth) {
this.masterPlaylistController_.mainSegmentLoader_.bandwidth = bandwidth;
}
}
});
Object.defineProperties(this.stats, {
bandwidth: {
get: () => this.bandwidth || 0,
enumerable: true
},
mediaRequests: {
get: () => this.masterPlaylistController_.mediaRequests_() || 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('sourceopen', () => {
this.tech_.audioTracks().addEventListener('change', this.audioTrackChange_);
});
this.masterPlaylistController_.on('selectedinitialmedia', () => {
// Add the manual rendition mix-in to HlsHandler
renditionSelectionMixin(this);
});
this.masterPlaylistController_.on('audioupdate', () => {
// clear current audioTracks
this.tech_.clearTracks('audio');
this.masterPlaylistController_.activeAudioGroup().forEach((audioTrack) => {
this.tech_.audioTracks().addTrack(audioTrack);
});
});
// the bandwidth of the primary segment loader is our best
// estimate of overall bandwidth
this.on(this.masterPlaylistController_, 'progress', function() {
this.bandwidth = this.masterPlaylistController_.mainSegmentLoader_.bandwidth;
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;
});
// 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));
}
/**
* a helper for grabbing the active audio group from MasterPlaylistController
*
* @private
*/
activeAudioGroup_() {
return this.masterPlaylistController_.activeAudioGroup();
}
/**
* 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.masterPlaylistController_) {
this.masterPlaylistController_.dispose();
}
this.gapSkipper_.dispose();
this.tech_.audioTracks().removeEventListener('change', this.audioTrackChange_);
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) {
// 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 (videojs.options.hls &&
videojs.options.hls.mode &&
videojs.options.hls.mode !== mode) {
return false;
}
return HlsSourceHandler.canPlayType(srcObj.type);
},
handleSource(source, tech, options) {
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);
}
let settings = videojs.mergeOptions(options, {hls: {mode}});
tech.hls = new HlsHandler(source, tech, settings);
tech.hls.xhr = xhrFactory();
// Use a global `before` function if specified on videojs.Hls.xhr
// but still allow for a per-player override
if (videojs.Hls.xhr.beforeRequest) {
tech.hls.xhr.beforeRequest = videojs.Hls.xhr.beforeRequest;
}
tech.hls.src(source.src);
return tech.hls;
},
canPlayType(type) {
if (HlsSourceHandler.canPlayType(type)) {
return 'maybe';
}
return '';
}
};
};
/**
* A comparator function to sort two playlist object by bandwidth.
*
* @param {Object} left a media playlist object
* @param {Object} right a media playlist object
* @return {Number} Greater than zero if the bandwidth attribute of
* left is greater than the corresponding attribute of right. Less
* than zero if the bandwidth of right is greater than left and
* exactly zero if the two are equal.
*/
Hls.comparePlaylistBandwidth = function(left, right) {
let leftBandwidth;
let rightBandwidth;
if (left.attributes && left.attributes.BANDWIDTH) {
leftBandwidth = left.attributes.BANDWIDTH;
}
leftBandwidth = leftBandwidth || window.Number.MAX_VALUE;
if (right.attributes && right.attributes.BANDWIDTH) {
rightBandwidth = right.attributes.BANDWIDTH;
}
rightBandwidth = rightBandwidth || window.Number.MAX_VALUE;
return leftBandwidth - rightBandwidth;
};
/**
* A comparator function to sort two playlist object by resolution (width).
* @param {Object} left a media playlist object
* @param {Object} right a media playlist object
* @return {Number} Greater than zero if the resolution.width attribute of
* left is greater than the corresponding attribute of right. Less
* than zero if the resolution.width of right is greater than left and
* exactly zero if the two are equal.
*/
Hls.comparePlaylistResolution = function(left, right) {
let leftWidth;
let rightWidth;
if (left.attributes &&
left.attributes.RESOLUTION &&
left.attributes.RESOLUTION.width) {
leftWidth = left.attributes.RESOLUTION.width;
}
leftWidth = leftWidth || window.Number.MAX_VALUE;
if (right.attributes &&
right.attributes.RESOLUTION &&
right.attributes.RESOLUTION.width) {
rightWidth = right.attributes.RESOLUTION.width;
}
rightWidth = rightWidth || window.Number.MAX_VALUE;
// NOTE - Fallback to bandwidth sort as appropriate in cases where multiple renditions
// have the same media dimensions/ resolution
if (leftWidth === rightWidth &&
left.attributes.BANDWIDTH &&
right.attributes.BANDWIDTH) {
return left.attributes.BANDWIDTH - right.attributes.BANDWIDTH;
}
return leftWidth - rightWidth;
};
HlsSourceHandler.canPlayType = function(type) {
let mpegurlRE = /^(audio|video|application)\/(x-|vnd\.apple\.)?mpegurl/i;
// favor native HLS support if it's available
if (Hls.supportsNativeHls) {
return false;
}
return mpegurlRE.test(type);
};
if (typeof videojs.MediaSource === 'undefined' ||
typeof videojs.URL === 'undefined') {
videojs.MediaSource = MediaSource;
videojs.URL = URL;
}
// register source handlers with the appropriate techs
if (MediaSource.supportsNativeMediaSources()) {
videojs.getComponent('Html5').registerSourceHandler(HlsSourceHandler('html5'));
}
if (window.Uint8Array) {
videojs.getComponent('Flash').registerSourceHandler(HlsSourceHandler('flash'));
}
videojs.HlsHandler = HlsHandler;
videojs.HlsSourceHandler = HlsSourceHandler;
videojs.Hls = Hls;
videojs.m3u8 = m3u8;
videojs.registerComponent('Hls', Hls);
videojs.options.hls = videojs.options.hls || {};
module.exports = {
Hls,
HlsHandler,
HlsSourceHandler
};