videojs-contrib-hls
Version:
Play back HLS with video.js, even where it's not natively supported.
1,561 lines (1,322 loc) • 51.2 kB
JavaScript
/*
* videojs-hls
* The main file for the HLS project.
* License: https://github.com/videojs/videojs-contrib-hls/blob/master/LICENSE
*/
(function(window, videojs, document, undefined) {
'use strict';
var
// A fudge factor to apply to advertised playlist bitrates to account for
// temporary flucations in client bandwidth
bandwidthVariance = 1.2,
blacklistDuration = 5 * 60 * 1000, // 5 minute blacklist
TIME_FUDGE_FACTOR = 1 / 30, // Fudge factor to account for TimeRanges rounding
Component = videojs.getComponent('Component'),
// The amount of time to wait between checking the state of the buffer
bufferCheckInterval = 500,
safeGetComputedStyle,
keyFailed,
resolveUrl;
// returns true if a key has failed to download within a certain amount of retries
keyFailed = function(key) {
return key.retries && key.retries >= 2;
};
videojs.Hls = {};
videojs.HlsHandler = videojs.extend(Component, {
constructor: function(tech, options) {
var self = this, _player;
Component.call(this, tech);
// tech.player() is deprecated but setup a reference to HLS for
// backwards-compatibility
if (tech.options_ && tech.options_.playerId) {
_player = videojs(tech.options_.playerId);
if (!_player.hls) {
Object.defineProperty(_player, 'hls', {
get: function() {
videojs.log.warn('player.hls is deprecated. Use player.tech.hls instead.');
return self;
}
});
}
}
this.tech_ = tech;
this.source_ = options.source;
this.mode_ = options.mode;
// the segment info object for a segment that is in the process of
// being downloaded or processed
this.pendingSegment_ = null;
// start playlist selection at a reasonable bandwidth for
// broadband internet
this.bandwidth = options.bandwidth || 4194304; // 0.5 Mbps
this.bytesReceived = 0;
// loadingState_ tracks how far along the buffering process we
// have been given permission to proceed. There are three possible
// values:
// - none: do not load playlists or segments
// - meta: load playlists but not segments
// - segments: load everything
this.loadingState_ = 'none';
if (this.tech_.preload() !== 'none') {
this.loadingState_ = 'meta';
}
// periodically check if new data needs to be downloaded or
// buffered data should be appended to the source buffer
this.startCheckingBuffer_();
this.on(this.tech_, 'seeking', function() {
this.setCurrentTime(this.tech_.currentTime());
});
this.on(this.tech_, 'error', function() {
this.stopCheckingBuffer_();
});
this.on(this.tech_, 'play', this.play);
}
});
// HLS is a source handler, not a tech. Make sure attempts to use it
// as one do not cause exceptions.
videojs.Hls.canPlaySource = function() {
return videojs.log.warn('HLS is no longer a tech. Please remove it from ' +
'your player\'s techOrder.');
};
/**
* 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.
*/
videojs.HlsSourceHandler = function(mode) {
return {
canHandleSource: function(srcObj) {
return videojs.HlsSourceHandler.canPlayType(srcObj.type);
},
handleSource: function(source, tech) {
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 videojs.HlsHandler(tech, {
source: source,
mode: mode
});
tech.hls.src(source.src);
return tech.hls;
},
canPlayType: function(type) {
return videojs.HlsSourceHandler.canPlayType(type);
}
};
};
videojs.HlsSourceHandler.canPlayType = function(type) {
var mpegurlRE = /^application\/(?:x-|vnd\.apple\.)mpegurl/i;
// favor native HLS support if it's available
if (videojs.Hls.supportsNativeHls) {
return false;
}
return mpegurlRE.test(type);
};
// register source handlers with the appropriate techs
if (videojs.MediaSource.supportsNativeMediaSources()) {
videojs.getComponent('Html5').registerSourceHandler(videojs.HlsSourceHandler('html5'));
}
if (window.Uint8Array) {
videojs.getComponent('Flash').registerSourceHandler(videojs.HlsSourceHandler('flash'));
}
// the desired length of video to maintain in the buffer, in seconds
videojs.Hls.GOAL_BUFFER_LENGTH = 30;
videojs.HlsHandler.prototype.src = function(src) {
var oldMediaPlaylist;
// do nothing if the src is falsey
if (!src) {
return;
}
this.mediaSource = new videojs.MediaSource({ mode: this.mode_ });
// load the MediaSource into the player
this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen.bind(this));
this.options_ = {};
if (this.source_.withCredentials !== undefined) {
this.options_.withCredentials = this.source_.withCredentials;
} else if (videojs.options.hls) {
this.options_.withCredentials = videojs.options.hls.withCredentials;
}
this.playlists = new videojs.Hls.PlaylistLoader(this.source_.src, this.options_.withCredentials);
this.tech_.one('canplay', this.setupFirstPlay.bind(this));
this.playlists.on('loadedmetadata', function() {
oldMediaPlaylist = this.playlists.media();
// if this isn't a live video and preload permits, start
// downloading segments
if (oldMediaPlaylist.endList &&
this.tech_.preload() !== 'metadata' &&
this.tech_.preload() !== 'none') {
this.loadingState_ = 'segments';
}
this.setupSourceBuffer_();
this.setupFirstPlay();
this.fillBuffer();
this.tech_.trigger('loadedmetadata');
}.bind(this));
this.playlists.on('error', function() {
this.blacklistCurrentPlaylist_(this.playlists.error);
}.bind(this));
this.playlists.on('loadedplaylist', function() {
var updatedPlaylist = this.playlists.media(), seekable;
if (!updatedPlaylist) {
// select the initial variant
this.playlists.media(this.selectPlaylist());
return;
}
this.updateDuration(this.playlists.media());
// update seekable
seekable = this.seekable();
if (this.duration() === Infinity &&
seekable.length !== 0) {
this.mediaSource.addSeekableRange_(seekable.start(0), seekable.end(0));
}
oldMediaPlaylist = updatedPlaylist;
}.bind(this));
this.playlists.on('mediachange', function() {
this.tech_.trigger({
type: 'mediachange',
bubbles: true
});
}.bind(this));
// 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.mediaSource));
};
videojs.HlsHandler.prototype.handleSourceOpen = function() {
// Only attempt to create the source buffer if none already exist.
// handleSourceOpen is also called when we are "re-opening" a source buffer
// after `endOfStream` has been called (in response to a seek for instance)
if (!this.sourceBuffer) {
this.setupSourceBuffer_();
}
// if autoplay is enabled, begin playback. This is duplicative of
// code in video.js but is required because play() must be invoked
// *after* the media source has opened.
// NOTE: moving this invocation of play() after
// sourceBuffer.appendBuffer() below caused live streams with
// autoplay to stall
if (this.tech_.autoplay()) {
this.play();
}
};
// Search for a likely end time for the segment that was just appened
// based on the state of the `buffered` property before and after the
// append.
// If we found only one such uncommon end-point return it.
videojs.Hls.findSoleUncommonTimeRangesEnd_ = function(original, update) {
var
i, start, end,
result = [],
edges = [],
// In order to qualify as a possible candidate, the end point must:
// 1) Not have already existed in the `original` ranges
// 2) Not result from the shrinking of a range that already existed
// in the `original` ranges
// 3) Not be contained inside of a range that existed in `original`
overlapsCurrentEnd = function(span) {
return (span[0] <= end && span[1] >= end);
};
if (original) {
// Save all the edges in the `original` TimeRanges object
for (i = 0; i < original.length; i++) {
start = original.start(i);
end = original.end(i);
edges.push([start, end]);
}
}
if (update) {
// Save any end-points in `update` that are not in the `original`
// TimeRanges object
for (i = 0; i < update.length; i++) {
start = update.start(i);
end = update.end(i);
if (edges.some(overlapsCurrentEnd)) {
continue;
}
// at this point it must be a unique non-shrinking end edge
result.push(end);
}
}
// we err on the side of caution and return null if didn't find
// exactly *one* differing end edge in the search above
if (result.length !== 1) {
return null;
}
return result[0];
};
/**
* Updates segment with information about its end-point in time and, optionally,
* the segment duration if we have enough information to determine a segment duration
* accurately.
* @param playlist {object} a media playlist object
* @param segmentIndex {number} the index of segment we last appended
* @param segmentEnd {number} the known of the segment referenced by segmentIndex
*/
videojs.HlsHandler.prototype.updateSegmentMetadata_ = function(playlist, segmentIndex, segmentEnd) {
var
segment,
previousSegment;
if (!playlist) {
return;
}
segment = playlist.segments[segmentIndex];
previousSegment = playlist.segments[segmentIndex - 1];
if (segmentEnd && segment) {
segment.end = segmentEnd;
// fix up segment durations based on segment end data
if (!previousSegment) {
// first segment is always has a start time of 0 making its duration
// equal to the segment end
segment.duration = segment.end;
} else if (previousSegment.end) {
segment.duration = segment.end - previousSegment.end;
}
}
};
/**
* Determines if we should call endOfStream on the media source based on the state
* of the buffer or if appened segment was the final segment in the playlist.
* @param playlist {object} a media playlist object
* @param segmentIndex {number} the index of segment we last appended
* @param currentBuffered {object} the buffered region that currentTime resides in
* @return {boolean} whether the calling function should call endOfStream on the MediaSource
*/
videojs.HlsHandler.prototype.isEndOfStream_ = function(playlist, segmentIndex, currentBuffered) {
var
segments = playlist.segments,
appendedLastSegment,
bufferedToEnd;
if (!playlist) {
return false;
}
// determine a few boolean values to help make the branch below easier
// to read
appendedLastSegment = (segmentIndex === segments.length - 1);
bufferedToEnd = (currentBuffered.length &&
segments[segments.length - 1].end <= currentBuffered.end(0));
// if we've buffered to the end of the video, we need to call endOfStream
// so that MediaSources can trigger the `ended` event when it runs out of
// buffered data instead of waiting for me
return playlist.endList &&
this.mediaSource.readyState === 'open' &&
(appendedLastSegment || bufferedToEnd);
};
var parseCodecs = function(codecs) {
var result = {
codecCount: 0,
videoCodec: null,
audioProfile: null
};
result.codecCount = codecs.split(',').length;
result.codecCount = result.codecCount || 2;
// parse the video codec but ignore the version
result.videoCodec = /(^|\s|,)+(avc1)[^ ,]*/i.exec(codecs);
result.videoCodec = result.videoCodec && result.videoCodec[2];
// parse the last field of the audio codec
result.audioProfile = /(^|\s|,)+mp4a.\d+\.(\d+)/i.exec(codecs);
result.audioProfile = result.audioProfile && result.audioProfile[2];
return result;
};
/**
* Blacklist playlists that are known to be codec or
* stream-incompatible with the SourceBuffer configuration. For
* instance, Media Source Extensions would cause the video element to
* stall waiting for video data if you switched from a variant with
* video and audio to an audio-only one.
*
* @param media {object} a media playlist compatible with the current
* set of SourceBuffers. Variants in the current master playlist that
* do not appear to have compatible codec or stream configurations
* will be excluded from the default playlist selection algorithm
* indefinitely.
*/
videojs.HlsHandler.prototype.excludeIncompatibleVariants_ = function(media) {
var
master = this.playlists.master,
codecCount = 2,
videoCodec = null,
audioProfile = null,
codecs;
if (media.attributes && media.attributes.CODECS) {
codecs = parseCodecs(media.attributes.CODECS);
videoCodec = codecs.videoCodec;
audioProfile = codecs.audioProfile;
codecCount = codecs.codecCount;
}
master.playlists.forEach(function(variant) {
var variantCodecs = {
codecCount: 2,
videoCodec: null,
audioProfile: null
};
if (variant.attributes && variant.attributes.CODECS) {
variantCodecs = parseCodecs(variant.attributes.CODECS);
}
// if the streams differ in the presence or absence of audio or
// video, they are incompatible
if (variantCodecs.codecCount !== codecCount) {
variant.excludeUntil = Infinity;
}
// if h.264 is specified on the current playlist, some flavor of
// it must be specified on all compatible variants
if (variantCodecs.videoCodec !== videoCodec) {
variant.excludeUntil = Infinity;
}
// HE-AAC ("mp4a.40.5") is incompatible with all other versions of
// AAC audio in Chrome 46. Don't mix the two.
if ((variantCodecs.audioProfile === '5' && audioProfile !== '5') ||
(audioProfile === '5' && variantCodecs.audioProfile !== '5')) {
variant.excludeUntil = Infinity;
}
});
};
videojs.HlsHandler.prototype.setupSourceBuffer_ = function() {
var media = this.playlists.media(), mimeType;
// wait until a media playlist is available and the Media Source is
// attached
if (!media || this.mediaSource.readyState !== 'open') {
return;
}
// if the codecs were explicitly specified, pass them along to the
// source buffer
mimeType = 'video/mp2t';
if (media.attributes && media.attributes.CODECS) {
mimeType += '; codecs="' + media.attributes.CODECS + '"';
}
this.sourceBuffer = this.mediaSource.addSourceBuffer(mimeType);
// exclude any incompatible variant streams from future playlist
// selection
this.excludeIncompatibleVariants_(media);
// transition the sourcebuffer to the ended state if we've hit the end of
// the playlist
this.sourceBuffer.addEventListener('updateend', this.updateEndHandler_.bind(this));
};
/**
* Seek to the latest media position if this is a live video and the
* player and video are loaded and initialized.
*/
videojs.HlsHandler.prototype.setupFirstPlay = function() {
var seekable, media;
media = this.playlists.media();
// check that everything is ready to begin buffering
// 1) the video is a live stream of unknown duration
if (this.duration() === Infinity &&
// 2) the player has not played before and is not paused
this.tech_.played().length === 0 &&
!this.tech_.paused() &&
// 3) the Media Source and Source Buffers are ready
this.sourceBuffer &&
// 4) the active media playlist is available
media &&
// 5) the video element or flash player is in a readyState of
// at least HAVE_FUTURE_DATA
this.tech_.readyState() >= 1) {
// trigger the playlist loader to start "expired time"-tracking
this.playlists.trigger('firstplay');
// seek to the latest media position for live videos
seekable = this.seekable();
if (seekable.length) {
this.tech_.setCurrentTime(seekable.end(0));
}
}
};
/**
* Begin playing the video.
*/
videojs.HlsHandler.prototype.play = function() {
this.loadingState_ = 'segments';
if (this.tech_.ended()) {
this.tech_.setCurrentTime(0);
}
if (this.tech_.played().length === 0) {
return this.setupFirstPlay();
}
// if the viewer has paused and we fell out of the live window,
// seek forward to the earliest available position
if (this.duration() === Infinity) {
if (this.tech_.currentTime() < this.seekable().start(0)) {
this.tech_.setCurrentTime(this.seekable().start(0));
}
}
};
videojs.HlsHandler.prototype.setCurrentTime = function(currentTime) {
var
buffered = this.findBufferedRange_();
if (!(this.playlists && this.playlists.media())) {
// return immediately if the metadata is not ready yet
return 0;
}
// it's clearly an edge-case but don't thrown an error if asked to
// seek within an empty playlist
if (!this.playlists.media().segments) {
return 0;
}
// if the seek location is already buffered, continue buffering as
// usual
if (buffered && buffered.length) {
return currentTime;
}
// if we are in the middle of appending a segment, let it finish up
if (this.pendingSegment_ && this.pendingSegment_.buffered) {
return currentTime;
}
this.lastSegmentLoaded_ = null;
// cancel outstanding requests and buffer appends
this.cancelSegmentXhr();
// abort outstanding key requests, if necessary
if (this.keyXhr_) {
this.keyXhr_.aborted = true;
this.cancelKeyXhr();
}
// begin filling the buffer at the new position
this.fillBuffer(this.playlists.getMediaIndexForTime_(currentTime));
};
videojs.HlsHandler.prototype.duration = function() {
var
playlists = this.playlists;
if (!playlists) {
return 0;
}
if (this.mediaSource) {
return this.mediaSource.duration;
}
return videojs.Hls.Playlist.duration(playlists.media());
};
videojs.HlsHandler.prototype.seekable = function() {
var media, seekable;
if (!this.playlists) {
return videojs.createTimeRanges();
}
media = this.playlists.media();
if (!media) {
return videojs.createTimeRanges();
}
seekable = videojs.Hls.Playlist.seekable(media);
if (seekable.length === 0) {
return seekable;
}
// if the seekable start is zero, it may be because the player has
// been paused for a long time and stopped buffering. in that case,
// fall back to the playlist loader's running estimate of expired
// time
if (seekable.start(0) === 0) {
return videojs.createTimeRanges([[
this.playlists.expired_,
this.playlists.expired_ + seekable.end(0)
]]);
}
// seekable has been calculated based on buffering video data so it
// can be returned directly
return seekable;
};
/**
* Update the player duration
*/
videojs.HlsHandler.prototype.updateDuration = function(playlist) {
var oldDuration = this.mediaSource.duration,
newDuration = videojs.Hls.Playlist.duration(playlist),
buffered = this.tech_.buffered(),
setDuration = function() {
this.mediaSource.duration = newDuration;
this.tech_.trigger('durationchange');
this.mediaSource.removeEventListener('sourceopen', setDuration);
}.bind(this);
if (buffered.length > 0) {
newDuration = Math.max(newDuration, buffered.end(buffered.length - 1));
}
// if the duration has changed, invalidate the cached value
if (oldDuration !== newDuration) {
// update the duration
if (this.mediaSource.readyState !== 'open') {
this.mediaSource.addEventListener('sourceopen', setDuration);
} else if (!this.sourceBuffer || !this.sourceBuffer.updating) {
this.mediaSource.duration = newDuration;
this.tech_.trigger('durationchange');
}
}
};
/**
* Clear all buffers and reset any state relevant to the current
* source. After this function is called, the tech should be in a
* state suitable for switching to a different video.
*/
videojs.HlsHandler.prototype.resetSrc_ = function() {
this.cancelSegmentXhr();
this.cancelKeyXhr();
if (this.sourceBuffer && this.mediaSource.readyState === 'open') {
this.sourceBuffer.abort();
}
};
videojs.HlsHandler.prototype.cancelKeyXhr = function() {
if (this.keyXhr_) {
this.keyXhr_.onreadystatechange = null;
this.keyXhr_.abort();
this.keyXhr_ = null;
}
};
videojs.HlsHandler.prototype.cancelSegmentXhr = function() {
if (this.segmentXhr_) {
// Prevent error handler from running.
this.segmentXhr_.onreadystatechange = null;
this.segmentXhr_.abort();
this.segmentXhr_ = null;
}
// clear out the segment being processed
this.pendingSegment_ = null;
};
/**
* 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
*/
safeGetComputedStyle = function(el, property) {
var result;
if (!el) {
return '';
}
result = getComputedStyle(el);
if (!result) {
return '';
}
return result[property];
};
/**
* Abort all outstanding work and cleanup.
*/
videojs.HlsHandler.prototype.dispose = function() {
this.stopCheckingBuffer_();
if (this.playlists) {
this.playlists.dispose();
}
this.resetSrc_();
Component.prototype.dispose.call(this);
};
/**
* Chooses the appropriate media playlist based on the current
* bandwidth estimate and the player size.
* @return the highest bitrate playlist less than the currently detected
* bandwidth, accounting for some amount of bandwidth variance
*/
videojs.HlsHandler.prototype.selectPlaylist = function () {
var
effectiveBitrate,
sortedPlaylists = this.playlists.master.playlists.slice(),
bandwidthPlaylists = [],
now = +new Date(),
i,
variant,
bandwidthBestVariant,
resolutionPlusOne,
resolutionBestVariant,
width,
height;
sortedPlaylists.sort(videojs.Hls.comparePlaylistBandwidth);
// filter out any playlists that have been excluded due to
// incompatible configurations or playback errors
sortedPlaylists = sortedPlaylists.filter(function(variant) {
if (variant.excludeUntil !== undefined) {
return now >= variant.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 * bandwidthVariance;
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(videojs.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
if (variant.attributes.RESOLUTION.width === width &&
variant.attributes.RESOLUTION.height === height) {
// if we have the exact resolution as the player use it
resolutionPlusOne = null;
resolutionBestVariant = variant;
break;
} else if (variant.attributes.RESOLUTION.width < width &&
variant.attributes.RESOLUTION.height < height) {
// if both dimensions are less than the player use the
// previous (next-largest) variant
break;
} else if (!resolutionPlusOne ||
(variant.attributes.RESOLUTION.width < resolutionPlusOne.attributes.RESOLUTION.width &&
variant.attributes.RESOLUTION.height < resolutionPlusOne.attributes.RESOLUTION.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;
}
}
// fallback chain of variants
return resolutionPlusOne || resolutionBestVariant || bandwidthBestVariant || sortedPlaylists[0];
};
/**
* Periodically request new segments and append video data.
*/
videojs.HlsHandler.prototype.checkBuffer_ = function() {
// calling this method directly resets any outstanding buffer checks
if (this.checkBufferTimeout_) {
window.clearTimeout(this.checkBufferTimeout_);
this.checkBufferTimeout_ = null;
}
this.fillBuffer();
this.drainBuffer();
// wait awhile and try again
this.checkBufferTimeout_ = window.setTimeout((this.checkBuffer_).bind(this),
bufferCheckInterval);
};
/**
* Setup a periodic task to request new segments if necessary and
* append bytes into the SourceBuffer.
*/
videojs.HlsHandler.prototype.startCheckingBuffer_ = function() {
this.checkBuffer_();
};
/**
* Stop the periodic task requesting new segments and feeding the
* SourceBuffer.
*/
videojs.HlsHandler.prototype.stopCheckingBuffer_ = function() {
if (this.checkBufferTimeout_) {
window.clearTimeout(this.checkBufferTimeout_);
this.checkBufferTimeout_ = null;
}
};
var filterBufferedRanges = function(predicate) {
return function(time) {
var
i,
ranges = [],
tech = this.tech_,
// !!The order of the next two assignments is important!!
// `currentTime` must be equal-to or greater-than the start of the
// buffered range. Flash executes out-of-process so, every value can
// change behind the scenes from line-to-line. By reading `currentTime`
// after `buffered`, we ensure that it is always a current or later
// value during playback.
buffered = tech.buffered();
if (time === undefined) {
time = tech.currentTime();
}
// IE 11 has a bug where it will report a the video as fully buffered
// before any data has been loaded. This is a work around where we
// report a fully empty buffer until SourceBuffers have been created
// which is after a segment has been loaded and transmuxed.
if (!this.mediaSource ||
!this.mediaSource.mediaSource_.sourceBuffers.length) {
return videojs.createTimeRanges([]);
}
if (buffered && buffered.length) {
// Search for a range containing the play-head
for (i = 0; i < buffered.length; i++) {
if (predicate(buffered.start(i), buffered.end(i), time)) {
ranges.push([buffered.start(i), buffered.end(i)]);
}
}
}
return videojs.createTimeRanges(ranges);
};
};
/**
* Attempts to find the buffered TimeRange that contains the specified
* time, or where playback is currently happening if no specific time
* is specified.
* @param time (optional) {number} the time to filter on. Defaults to
* currentTime.
* @return a new TimeRanges object.
*/
videojs.HlsHandler.prototype.findBufferedRange_ = filterBufferedRanges(function(start, end, time) {
return start - TIME_FUDGE_FACTOR <= time &&
end + TIME_FUDGE_FACTOR >= time;
});
/**
* Returns the TimeRanges that begin at or later than the specified
* time.
* @param time (optional) {number} the time to filter on. Defaults to
* currentTime.
* @return a new TimeRanges object.
*/
videojs.HlsHandler.prototype.findNextBufferedRange_ = filterBufferedRanges(function(start, end, time) {
return start - TIME_FUDGE_FACTOR >= time;
});
/**
* Determines whether there is enough video data currently in the buffer
* and downloads a new segment if the buffered time is less than the goal.
* @param seekToTime (optional) {number} the offset into the downloaded segment
* to seek to, in seconds
*/
videojs.HlsHandler.prototype.fillBuffer = function(mediaIndex) {
var
tech = this.tech_,
currentTime = tech.currentTime(),
hasBufferedContent = (this.tech_.buffered().length !== 0),
currentBuffered = this.findBufferedRange_(),
outsideBufferedRanges = !(currentBuffered && currentBuffered.length),
currentBufferedEnd = 0,
bufferedTime = 0,
segment,
segmentInfo,
segmentTimestampOffset;
// if preload is set to "none", do not download segments until playback is requested
if (this.loadingState_ !== 'segments') {
return;
}
// if a video has not been specified, do nothing
if (!tech.currentSrc() || !this.playlists) {
return;
}
// if there is a request already in flight, do nothing
if (this.segmentXhr_) {
return;
}
// wait until the buffer is up to date
if (this.pendingSegment_) {
return;
}
// if no segments are available, do nothing
if (this.playlists.state === "HAVE_NOTHING" ||
!this.playlists.media() ||
!this.playlists.media().segments) {
return;
}
// if a playlist switch is in progress, wait for it to finish
if (this.playlists.state === 'SWITCHING_MEDIA') {
return;
}
if (mediaIndex === undefined) {
if (currentBuffered && currentBuffered.length) {
currentBufferedEnd = currentBuffered.end(0);
mediaIndex = this.playlists.getMediaIndexForTime_(currentBufferedEnd);
bufferedTime = Math.max(0, currentBufferedEnd - currentTime);
// if there is plenty of content in the buffer and we're not
// seeking, relax for awhile
if (bufferedTime >= videojs.Hls.GOAL_BUFFER_LENGTH) {
return;
}
} else {
mediaIndex = this.playlists.getMediaIndexForTime_(this.tech_.currentTime());
}
}
segment = this.playlists.media().segments[mediaIndex];
// if the video has finished downloading
if (!segment) {
return;
}
// we have entered a state where we are fetching the same segment,
// try to walk forward
if (this.lastSegmentLoaded_ &&
this.playlistUriToUrl(this.lastSegmentLoaded_.uri) === this.playlistUriToUrl(segment.uri) &&
this.lastSegmentLoaded_.byterange === segment.byterange) {
return this.fillBuffer(mediaIndex + 1);
}
// package up all the work to append the segment
segmentInfo = {
// resolve the segment URL relative to the playlist
uri: this.playlistUriToUrl(segment.uri),
// the segment's mediaIndex & mediaSequence at the time it was requested
mediaIndex: mediaIndex,
mediaSequence: this.playlists.media().mediaSequence,
// the segment's playlist
playlist: this.playlists.media(),
// The state of the buffer when this segment was requested
currentBufferedEnd: currentBufferedEnd,
// unencrypted bytes of the segment
bytes: null,
// when a key is defined for this segment, the encrypted bytes
encryptedBytes: null,
// optionally, the decrypter that is unencrypting the segment
decrypter: null,
// the state of the buffer before a segment is appended will be
// stored here so that the actual segment duration can be
// determined after it has been appended
buffered: null,
// The target timestampOffset for this segment when we append it
// to the source buffer
timestampOffset: null
};
if (mediaIndex > 0) {
segmentTimestampOffset = videojs.Hls.Playlist.duration(segmentInfo.playlist,
segmentInfo.playlist.mediaSequence + mediaIndex) + this.playlists.expired_;
}
if (this.tech_.seeking() && outsideBufferedRanges) {
// If there are discontinuities in the playlist, we can't be sure of anything
// related to time so we reset the timestamp offset and start appending data
// anew on every seek
if (segmentInfo.playlist.discontinuityStarts.length) {
segmentInfo.timestampOffset = segmentTimestampOffset;
}
} else if (segment.discontinuity && currentBuffered.length) {
// If we aren't seeking and are crossing a discontinuity, we should set
// timestampOffset for new segments to be appended the end of the current
// buffered time-range
segmentInfo.timestampOffset = currentBuffered.end(0);
} else if (!hasBufferedContent && this.tech_.currentTime() > 0.05) {
// If we are trying to play at a position that is not zero but we aren't
// currently seeking according to the video element
segmentInfo.timestampOffset = segmentTimestampOffset;
}
this.loadSegment(segmentInfo);
};
videojs.HlsHandler.prototype.playlistUriToUrl = function(segmentRelativeUrl) {
var playListUrl;
// resolve the segment URL relative to the playlist
if (this.playlists.media().uri === this.source_.src) {
playListUrl = resolveUrl(this.source_.src, segmentRelativeUrl);
} else {
playListUrl = resolveUrl(resolveUrl(this.source_.src, this.playlists.media().uri || ''), segmentRelativeUrl);
}
return playListUrl;
};
/* Turns segment byterange into a string suitable for use in
* HTTP Range requests
*/
videojs.HlsHandler.prototype.byterangeStr_ = function(byterange) {
var byterangeStart, byterangeEnd;
// `byterangeEnd` is one less than `offset + length` because the HTTP range
// header uses inclusive ranges
byterangeEnd = byterange.offset + byterange.length - 1;
byterangeStart = byterange.offset;
return "bytes=" + byterangeStart + "-" + byterangeEnd;
};
/* Defines headers for use in the xhr request for a particular segment.
*/
videojs.HlsHandler.prototype.segmentXhrHeaders_ = function(segment) {
var headers = {};
if ('byterange' in segment) {
headers['Range'] = this.byterangeStr_(segment.byterange);
}
return headers;
};
/*
* Sets `bandwidth`, `segmentXhrTime`, and appends to the `bytesReceived.
* Expects an object with:
* * `roundTripTime` - the round trip time for the request we're setting the time for
* * `bandwidth` - the bandwidth we want to set
* * `bytesReceived` - amount of bytes downloaded
* `bandwidth` is the only required property.
*/
videojs.HlsHandler.prototype.setBandwidth = function(xhr) {
// calculate the download bandwidth
this.segmentXhrTime = xhr.roundTripTime;
this.bandwidth = xhr.bandwidth;
this.bytesReceived += xhr.bytesReceived || 0;
this.tech_.trigger('bandwidthupdate');
};
/*
* Blacklists a playlist when an error occurs for a set amount of time
* making it unavailable for selection by the rendition selection algorithm
* and then forces a new playlist (rendition) selection.
*/
videojs.HlsHandler.prototype.blacklistCurrentPlaylist_ = function(error) {
var currentPlaylist, nextPlaylist;
// If the `error` was generated by the playlist loader, it will contain
// the playlist we were trying to load (but failed) and that should be
// blacklisted instead of the currently selected playlist which is likely
// out-of-date in this scenario
currentPlaylist = error.playlist || this.playlists.media();
// If there is no current playlist, then an error occurred while we were
// trying to load the master OR while we were disposing of the tech
if (!currentPlaylist) {
this.error = error;
return this.mediaSource.endOfStream('network');
}
// Blacklist this playlist
currentPlaylist.excludeUntil = Date.now() + blacklistDuration;
// Select a new playlist
nextPlaylist = this.selectPlaylist();
if (nextPlaylist) {
videojs.log.warn('Problem encountered with the current HLS playlist. Switching to another playlist.');
return this.playlists.media(nextPlaylist);
} else {
videojs.log.warn('Problem encountered with the current HLS playlist. No suitable alternatives found.');
// We have no more playlists we can select so we must fail
this.error = error;
return this.mediaSource.endOfStream('network');
}
};
videojs.HlsHandler.prototype.loadSegment = function(segmentInfo) {
var
self = this,
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex],
removeToTime = 0,
seekable = this.seekable(),
currentTime = this.tech_.currentTime();
// Chrome has a hard limit of 150mb of buffer and a very conservative "garbage collector"
// We manually clear out the old buffer to ensure we don't trigger the QuotaExceeded error
// on the source buffer during subsequent appends
if (this.sourceBuffer && !this.sourceBuffer.updating) {
// If we have a seekable range use that as the limit for what can be removed safely
// otherwise remove anything older than 1 minute before the current play head
if (seekable.length && seekable.start(0) > 0 && seekable.start(0) < currentTime) {
removeToTime = seekable.start(0);
} else {
removeToTime = currentTime - 60;
}
if (removeToTime > 0) {
this.sourceBuffer.remove(0, removeToTime);
}
}
// if the segment is encrypted, request the key
if (segment.key) {
this.fetchKey_(segment);
}
// request the next segment
this.segmentXhr_ = videojs.Hls.xhr({
uri: segmentInfo.uri,
responseType: 'arraybuffer',
withCredentials: this.source_.withCredentials,
// Set xhr timeout to 150% of the segment duration to allow us
// some time to switch renditions in the event of a catastrophic
// decrease in network performance or a server issue.
timeout: (segment.duration * 1.5) * 1000,
headers: this.segmentXhrHeaders_(segment)
}, function(error, request) {
// This is a timeout of a previously aborted segment request
// so simply ignore it
if (!self.segmentXhr_ || request !== self.segmentXhr_) {
return;
}
// the segment request is no longer outstanding
self.segmentXhr_ = null;
// if a segment request times out, we may have better luck with another playlist
if (request.timedout) {
self.bandwidth = 1;
return self.playlists.media(self.selectPlaylist());
}
// otherwise, trigger a network error
if (!request.aborted && error) {
return self.blacklistCurrentPlaylist_({
status: request.status,
message: 'HLS segment request error at URL: ' + segmentInfo.uri,
code: (request.status >= 500) ? 4 : 2
});
}
// stop processing if the request was aborted
if (!request.response) {
return;
}
self.lastSegmentLoaded_ = segment;
self.setBandwidth(request);
if (segment.key) {
segmentInfo.encryptedBytes = new Uint8Array(request.response);
} else {
segmentInfo.bytes = new Uint8Array(request.response);
}
self.pendingSegment_ = segmentInfo;
self.tech_.trigger('progress');
self.drainBuffer();
// figure out what stream the next segment should be downloaded from
// with the updated bandwidth information
self.playlists.media(self.selectPlaylist());
});
};
videojs.HlsHandler.prototype.drainBuffer = function() {
var
segmentInfo,
mediaIndex,
playlist,
offset,
bytes,
segment,
decrypter,
segIv;
// if the buffer is empty or the source buffer hasn't been created
// yet, do nothing
if (!this.pendingSegment_ || !this.sourceBuffer) {
return;
}
// the pending segment has already been appended and we're waiting
// for updateend to fire
if (this.pendingSegment_.buffered) {
return;
}
// we can't append more data if the source buffer is busy processing
// what we've already sent
if (this.sourceBuffer.updating) {
return;
}
segmentInfo = this.pendingSegment_;
mediaIndex = segmentInfo.mediaIndex;
playlist = segmentInfo.playlist;
offset = segmentInfo.offset;
bytes = segmentInfo.bytes;
segment = playlist.segments[mediaIndex];
if (segment.key && !bytes) {
// this is an encrypted segment
// if the key download failed, we want to skip this segment
// but if the key hasn't downloaded yet, we want to try again later
if (keyFailed(segment.key)) {
return this.blacklistCurrentPlaylist_({
message: 'HLS segment key request error.',
code: 4
});
} else if (!segment.key.bytes) {
// waiting for the key bytes, try again later
return;
} else if (segmentInfo.decrypter) {
// decryption is in progress, try again later
return;
} else {
// if the media sequence is greater than 2^32, the IV will be incorrect
// assuming 10s segments, that would be about 1300 years
segIv = segment.key.iv || new Uint32Array([0, 0, 0, mediaIndex + playlist.mediaSequence]);
// create a decrypter to incrementally decrypt the segment
decrypter = new videojs.Hls.Decrypter(segmentInfo.encryptedBytes,
segment.key.bytes,
segIv,
function(err, bytes) {
segmentInfo.bytes = bytes;
});
segmentInfo.decrypter = decrypter;
return;
}
}
this.pendingSegment_.buffered = this.tech_.buffered();
if (segmentInfo.timestampOffset !== null) {
this.sourceBuffer.timestampOffset = segmentInfo.timestampOffset;
}
// the segment is asynchronously added to the current buffered data
this.sourceBuffer.appendBuffer(bytes);
};
videojs.HlsHandler.prototype.updateEndHandler_ = function () {
var
segmentInfo = this.pendingSegment_,
segment,
segments,
playlist,
currentMediaIndex,
currentBuffered,
seekable,
timelineUpdate,
isEndOfStream;
// stop here if the update errored or was aborted
if (!segmentInfo) {
return;
}
// In Firefox, the updateend event is triggered for both removing from the buffer and
// adding to the buffer. To prevent this code from executing on removals, we wait for
// segmentInfo to have a filled in buffered value before we continue processing.
if (!segmentInfo.buffered) {
return;
}
this.pendingSegment_ = null;
playlist = segmentInfo.playlist;
segments = playlist.segments;
currentMediaIndex = segmentInfo.mediaIndex + (segmentInfo.mediaSequence - playlist.mediaSequence);
currentBuffered = this.findBufferedRange_();
isEndOfStream = this.isEndOfStream_(playlist, currentMediaIndex, currentBuffered);
// if we switched renditions don't try to add segment timeline
// information to the playlist
if (segmentInfo.playlist.uri !== this.playlists.media().uri) {
if (isEndOfStream) {
return this.mediaSource.endOfStream();
}
return this.fillBuffer();
}
// annotate the segment with any start and end time information
// added by the media processing
segment = playlist.segments[currentMediaIndex];
// when seeking to the beginning of the seekable range, it's
// possible that imprecise timing information may cause the seek to
// end up earlier than the start of the range
// in that case, seek again
seekable = this.seekable();
if (this.tech_.seeking() &&
currentBuffered.length === 0) {
if (seekable.length &&
this.tech_.currentTime() < seekable.start(0)) {
var next = this.findNextBufferedRange_();
if (next.length) {
videojs.log('tried seeking to', this.tech_.currentTime(), 'but that was too early, retrying at', next.start(0));
this.tech_.setCurrentTime(next.start(0) + TIME_FUDGE_FACTOR);
}
}
}
timelineUpdate = videojs.Hls.findSoleUncommonTimeRangesEnd_(segmentInfo.buffered,
this.tech_.buffered());
// Update segment meta-data (duration and end-point) based on timeline
this.updateSegmentMetadata_(playlist, currentMediaIndex, timelineUpdate);
// If we decide to signal the end of stream, then we can return instead
// of trying to fetch more segments
if (isEndOfStream) {
return this.mediaSource.endOfStream();
}
if (timelineUpdate !== null ||
segmentInfo.buffered.length !== this.tech_.buffered().length) {
this.updateDuration(playlist);
// check if it's time to download the next segment
this.fillBuffer();
return;
}
// the last segment append must have been entirely in the
// already buffered time ranges. just buffer forward until we
// find a segment that adds to the buffered time ranges and
// improves subsequent media index calculations.
this.fillBuffer(currentMediaIndex + 1);
return;
};
/**
* Attempt to retrieve the key for a particular media segment.
*/
videojs.HlsHandler.prototype.fetchKey_ = function(segment) {
var key, self, settings, receiveKey;
// if there is a pending XHR or no segments, don't do anything
if (this.keyXhr_) {
return;
}
self = this;
settings = this.options_;
/**
* Handle a key XHR response.
*/
receiveKey = function(key) {
return function(error, request) {
var view;
self.keyXhr_ = null;
if (error || !request.response || request.response.byteLength !== 16) {
key.retries = key.retries || 0;
key.retries++;
if (!request.aborted) {
// try fetching again
self.fetchKey_(segment);
}
return;
}
view = new DataView(request.response);
key.bytes = new Uint32Array([
view.getUint32(0),
view.getUint32(4),
view.getUint32(8),
view.getUint32(12)
]);
// check to see if this allows us to make progress buffering now
self.checkBuffer_();
};
};
key = segment.key;
// nothing to do if this segment is unencrypted
if (!key) {
return;
}
// request the key if the retry limit hasn't been reached
if (!key.bytes && !keyFailed(key)) {
this.keyXhr_ = videojs.Hls.xhr({
uri: this.playlistUriToUrl(key.uri),
responseType: 'arraybuffer',
withCredentials: settings.withCredentials
}, receiveKey(key));
return;
}
};
/**
* Whether the browser has built-in HLS support.
*/
videojs.Hls.supportsNativeHls = (function() {
var
video = document.createElement('video'),
xMpegUrl,
vndMpeg;
// native HLS is definitely not supported if HTML5 video isn't
if (!videojs.getComponent('Html5').isSupported()) {
return false;
}
xMpegUrl = video.canPlayType('application/x-mpegURL');
vndMpeg = video.canPlayType('application/vnd.apple.mpegURL');
return (/probably|maybe/).test(xMpegUrl) ||
(/probably|maybe/).test(vndMpeg);
})();
// HLS is a source handler, not a tech. Make sure attempts to use it
// as one do not cause exceptions.
videojs.Hls.isSupported = function() {
return videojs.log.warn('HLS is no longer a tech. Please remove it from ' +
'your player\'s techOrder.');
};
/**
* A comparator function to sort two playlist object by bandwidth.
* @param left {object} a media playlist object
* @param right {object} 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.
*/
videojs.Hls.comparePlaylistBandwidth = function(left, right) {
var leftBandwidth, 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 left {object} a media playlist object
* @param right {object} 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.
*/
videojs.Hls.comparePlaylistResolution = function(left, right) {
var leftWidth, 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 -