videojs-contrib-hls
Version:
Play back HLS with video.js, even where it's not natively supported
1,332 lines (1,146 loc) • 42.9 kB
JavaScript
/**
* @file segment-loader.js
*/
import Playlist from './playlist';
import videojs from 'video.js';
import SourceUpdater from './source-updater';
import Config from './config';
import window from 'global/window';
import removeCuesFromTrack from
'videojs-contrib-media-sources/es5/remove-cues-from-track.js';
import { initSegmentId } from './bin-utils';
import {mediaSegmentRequest, REQUEST_ERRORS} from './media-segment-request';
import { TIME_FUDGE_FACTOR, timeUntilRebuffer as timeUntilRebuffer_ } from './ranges';
import { minRebufferMaxBandwidthSelector } from './playlist-selectors';
// in ms
const CHECK_BUFFER_DELAY = 500;
/**
* 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 {Object} playlist a media playlist object
* @param {Object} mediaSource the MediaSource object
* @param {Number} segmentIndex the index of segment we last appended
* @returns {Boolean} do we need to call endOfStream on the MediaSource
*/
const detectEndOfStream = function(playlist, mediaSource, segmentIndex) {
if (!playlist || !mediaSource) {
return false;
}
let segments = playlist.segments;
// determine a few boolean values to help make the branch below easier
// to read
let appendedLastSegment = segmentIndex === segments.length;
// 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 &&
mediaSource.readyState === 'open' &&
appendedLastSegment;
};
const finite = (num) => typeof num === 'number' && isFinite(num);
export const illegalMediaSwitch = (loaderType, startingMedia, newSegmentMedia) => {
// Although these checks should most likely cover non 'main' types, for now it narrows
// the scope of our checks.
if (loaderType !== 'main' || !startingMedia || !newSegmentMedia) {
return null;
}
if (!newSegmentMedia.containsAudio && !newSegmentMedia.containsVideo) {
return 'Neither audio nor video found in segment.';
}
if (startingMedia.containsVideo && !newSegmentMedia.containsVideo) {
return 'Only audio found in segment when we expected video.' +
' We can\'t switch to audio only from a stream that had video.' +
' To get rid of this message, please add codec information to the manifest.';
}
if (!startingMedia.containsVideo && newSegmentMedia.containsVideo) {
return 'Video found in segment when we expected only audio.' +
' We can\'t switch to a stream with video from an audio only stream.' +
' To get rid of this message, please add codec information to the manifest.';
}
return null;
};
/**
* Calculates a time value that is safe to remove from the back buffer without interupting
* playback.
*
* @param {TimeRange} seekable
* The current seekable range
* @param {Number} currentTime
* The current time of the player
* @param {Number} targetDuration
* The target duration of the current playlist
* @return {Number}
* Time that is safe to remove from the back buffer without interupting playback
*/
export const safeBackBufferTrimTime = (seekable, currentTime, targetDuration) => {
let removeToTime;
if (seekable.length &&
seekable.start(0) > 0 &&
seekable.start(0) < currentTime) {
// If we have a seekable range use that as the limit for what can be removed safely
removeToTime = seekable.start(0);
} else {
// otherwise remove anything older than 30 seconds before the current play head
removeToTime = currentTime - 30;
}
// Don't allow removing from the buffer within target duration of current time
// to avoid the possibility of removing the GOP currently being played which could
// cause playback stalls.
return Math.min(removeToTime, currentTime - targetDuration);
};
/**
* An object that manages segment loading and appending.
*
* @class SegmentLoader
* @param {Object} options required and optional options
* @extends videojs.EventTarget
*/
export default class SegmentLoader extends videojs.EventTarget {
constructor(settings, options = {}) {
super();
// check pre-conditions
if (!settings) {
throw new TypeError('Initialization settings are required');
}
if (typeof settings.currentTime !== 'function') {
throw new TypeError('No currentTime getter specified');
}
if (!settings.mediaSource) {
throw new TypeError('No MediaSource specified');
}
// public properties
this.state = 'INIT';
this.bandwidth = settings.bandwidth;
this.throughput = {rate: 0, count: 0};
this.roundTrip = NaN;
this.resetStats_();
this.mediaIndex = null;
// private settings
this.hasPlayed_ = settings.hasPlayed;
this.currentTime_ = settings.currentTime;
this.seekable_ = settings.seekable;
this.seeking_ = settings.seeking;
this.duration_ = settings.duration;
this.mediaSource_ = settings.mediaSource;
this.hls_ = settings.hls;
this.loaderType_ = settings.loaderType;
this.startingMedia_ = void 0;
this.segmentMetadataTrack_ = settings.segmentMetadataTrack;
this.goalBufferLength_ = settings.goalBufferLength;
// private instance variables
this.checkBufferTimeout_ = null;
this.error_ = void 0;
this.currentTimeline_ = -1;
this.pendingSegment_ = null;
this.mimeType_ = null;
this.sourceUpdater_ = null;
this.xhrOptions_ = null;
// Fragmented mp4 playback
this.activeInitSegmentId_ = null;
this.initSegments_ = {};
this.decrypter_ = settings.decrypter;
// Manages the tracking and generation of sync-points, mappings
// between a time in the display time and a segment index within
// a playlist
this.syncController_ = settings.syncController;
this.syncPoint_ = {
segmentIndex: 0,
time: 0
};
this.syncController_.on('syncinfoupdate', () => this.trigger('syncinfoupdate'));
this.mediaSource_.addEventListener('sourceopen', () => this.ended_ = false);
// ...for determining the fetch location
this.fetchAtBuffer_ = false;
if (options.debug) {
this.logger_ = videojs.log.bind(videojs, 'segment-loader', this.loaderType_, '->');
}
}
/**
* reset all of our media stats
*
* @private
*/
resetStats_() {
this.mediaBytesTransferred = 0;
this.mediaRequests = 0;
this.mediaRequestsAborted = 0;
this.mediaRequestsTimedout = 0;
this.mediaRequestsErrored = 0;
this.mediaTransferDuration = 0;
this.mediaSecondsLoaded = 0;
}
/**
* dispose of the SegmentLoader and reset to the default state
*/
dispose() {
this.state = 'DISPOSED';
this.pause();
this.abort_();
if (this.sourceUpdater_) {
this.sourceUpdater_.dispose();
}
this.resetStats_();
}
/**
* abort anything that is currently doing on with the SegmentLoader
* and reset to a default state
*/
abort() {
if (this.state !== 'WAITING') {
if (this.pendingSegment_) {
this.pendingSegment_ = null;
}
return;
}
this.abort_();
// We aborted the requests we were waiting on, so reset the loader's state to READY
// since we are no longer "waiting" on any requests. XHR callback is not always run
// when the request is aborted. This will prevent the loader from being stuck in the
// WAITING state indefinitely.
this.state = 'READY';
// don't wait for buffer check timeouts to begin fetching the
// next segment
if (!this.paused()) {
this.monitorBuffer_();
}
}
/**
* abort all pending xhr requests and null any pending segements
*
* @private
*/
abort_() {
if (this.pendingSegment_) {
this.pendingSegment_.abortRequests();
}
// clear out the segment being processed
this.pendingSegment_ = null;
}
/**
* set an error on the segment loader and null out any pending segements
*
* @param {Error} error the error to set on the SegmentLoader
* @return {Error} the error that was set or that is currently set
*/
error(error) {
if (typeof error !== 'undefined') {
this.error_ = error;
}
this.pendingSegment_ = null;
return this.error_;
}
endOfStream() {
this.ended_ = true;
this.pause();
this.trigger('ended');
}
/**
* Indicates which time ranges are buffered
*
* @return {TimeRange}
* TimeRange object representing the current buffered ranges
*/
buffered_() {
if (!this.sourceUpdater_) {
return videojs.createTimeRanges();
}
return this.sourceUpdater_.buffered();
}
/**
* Gets and sets init segment for the provided map
*
* @param {Object} map
* The map object representing the init segment to get or set
* @param {Boolean=} set
* If true, the init segment for the provided map should be saved
* @return {Object}
* map object for desired init segment
*/
initSegment(map, set = false) {
if (!map) {
return null;
}
const id = initSegmentId(map);
let storedMap = this.initSegments_[id];
if (set && !storedMap && map.bytes) {
this.initSegments_[id] = storedMap = {
resolvedUri: map.resolvedUri,
byterange: map.byterange,
bytes: map.bytes
};
}
return storedMap || map;
}
/**
* Returns true if all configuration required for loading is present, otherwise false.
*
* @return {Boolean} True if the all configuration is ready for loading
* @private
*/
couldBeginLoading_() {
return this.playlist_ &&
// the source updater is created when init_ is called, so either having a
// source updater or being in the INIT state with a mimeType is enough
// to say we have all the needed configuration to start loading.
(this.sourceUpdater_ || (this.mimeType_ && this.state === 'INIT')) &&
!this.paused();
}
/**
* load a playlist and start to fill the buffer
*/
load() {
// un-pause
this.monitorBuffer_();
// if we don't have a playlist yet, keep waiting for one to be
// specified
if (!this.playlist_) {
return;
}
// not sure if this is the best place for this
this.syncController_.setDateTimeMapping(this.playlist_);
// if all the configuration is ready, initialize and begin loading
if (this.state === 'INIT' && this.couldBeginLoading_()) {
return this.init_();
}
// if we're in the middle of processing a segment already, don't
// kick off an additional segment request
if (!this.couldBeginLoading_() ||
(this.state !== 'READY' &&
this.state !== 'INIT')) {
return;
}
this.state = 'READY';
}
/**
* Once all the starting parameters have been specified, begin
* operation. This method should only be invoked from the INIT
* state.
*
* @private
*/
init_() {
this.state = 'READY';
this.sourceUpdater_ = new SourceUpdater(this.mediaSource_, this.mimeType_);
this.resetEverything();
return this.monitorBuffer_();
}
/**
* set a playlist on the segment loader
*
* @param {PlaylistLoader} media the playlist to set on the segment loader
*/
playlist(newPlaylist, options = {}) {
if (!newPlaylist) {
return;
}
let oldPlaylist = this.playlist_;
let segmentInfo = this.pendingSegment_;
this.playlist_ = newPlaylist;
this.xhrOptions_ = options;
// when we haven't started playing yet, the start of a live playlist
// is always our zero-time so force a sync update each time the playlist
// is refreshed from the server
if (!this.hasPlayed_()) {
newPlaylist.syncInfo = {
mediaSequence: newPlaylist.mediaSequence,
time: 0
};
}
// in VOD, this is always a rendition switch (or we updated our syncInfo above)
// in LIVE, we always want to update with new playlists (including refreshes)
this.trigger('syncinfoupdate');
// if we were unpaused but waiting for a playlist, start
// buffering now
if (this.state === 'INIT' && this.couldBeginLoading_()) {
return this.init_();
}
if (!oldPlaylist || oldPlaylist.uri !== newPlaylist.uri) {
if (this.mediaIndex !== null) {
// we must "resync" the segment loader when we switch renditions and
// the segment loader is already synced to the previous rendition
this.resyncLoader();
}
// the rest of this function depends on `oldPlaylist` being defined
return;
}
// we reloaded the same playlist so we are in a live scenario
// and we will likely need to adjust the mediaIndex
let mediaSequenceDiff = newPlaylist.mediaSequence - oldPlaylist.mediaSequence;
this.logger_('mediaSequenceDiff', mediaSequenceDiff);
// update the mediaIndex on the SegmentLoader
// this is important because we can abort a request and this value must be
// equal to the last appended mediaIndex
if (this.mediaIndex !== null) {
this.mediaIndex -= mediaSequenceDiff;
}
// update the mediaIndex on the SegmentInfo object
// this is important because we will update this.mediaIndex with this value
// in `handleUpdateEnd_` after the segment has been successfully appended
if (segmentInfo) {
segmentInfo.mediaIndex -= mediaSequenceDiff;
// we need to update the referenced segment so that timing information is
// saved for the new playlist's segment, however, if the segment fell off the
// playlist, we can leave the old reference and just lose the timing info
if (segmentInfo.mediaIndex >= 0) {
segmentInfo.segment = newPlaylist.segments[segmentInfo.mediaIndex];
}
}
this.syncController_.saveExpiredSegmentInfo(oldPlaylist, newPlaylist);
}
/**
* Prevent the loader from fetching additional segments. If there
* is a segment request outstanding, it will finish processing
* before the loader halts. A segment loader can be unpaused by
* calling load().
*/
pause() {
if (this.checkBufferTimeout_) {
window.clearTimeout(this.checkBufferTimeout_);
this.checkBufferTimeout_ = null;
}
}
/**
* Returns whether the segment loader is fetching additional
* segments when given the opportunity. This property can be
* modified through calls to pause() and load().
*/
paused() {
return this.checkBufferTimeout_ === null;
}
/**
* create/set the following mimetype on the SourceBuffer through a
* SourceUpdater
*
* @param {String} mimeType the mime type string to use
*/
mimeType(mimeType) {
if (this.mimeType_) {
return;
}
this.mimeType_ = mimeType;
// if we were unpaused but waiting for a sourceUpdater, start
// buffering now
if (this.state === 'INIT' && this.couldBeginLoading_()) {
this.init_();
}
}
/**
* Delete all the buffered data and reset the SegmentLoader
*/
resetEverything() {
this.ended_ = false;
this.resetLoader();
this.remove(0, this.duration_());
this.trigger('reseteverything');
}
/**
* Force the SegmentLoader to resync and start loading around the currentTime instead
* of starting at the end of the buffer
*
* Useful for fast quality changes
*/
resetLoader() {
this.fetchAtBuffer_ = false;
this.resyncLoader();
}
/**
* Force the SegmentLoader to restart synchronization and make a conservative guess
* before returning to the simple walk-forward method
*/
resyncLoader() {
this.mediaIndex = null;
this.syncPoint_ = null;
this.abort();
}
/**
* Remove any data in the source buffer between start and end times
* @param {Number} start - the start time of the region to remove from the buffer
* @param {Number} end - the end time of the region to remove from the buffer
*/
remove(start, end) {
if (this.sourceUpdater_) {
this.sourceUpdater_.remove(start, end);
}
removeCuesFromTrack(start, end, this.segmentMetadataTrack_);
}
/**
* (re-)schedule monitorBufferTick_ to run as soon as possible
*
* @private
*/
monitorBuffer_() {
if (this.checkBufferTimeout_) {
window.clearTimeout(this.checkBufferTimeout_);
}
this.checkBufferTimeout_ = window.setTimeout(this.monitorBufferTick_.bind(this), 1);
}
/**
* As long as the SegmentLoader is in the READY state, periodically
* invoke fillBuffer_().
*
* @private
*/
monitorBufferTick_() {
if (this.state === 'READY') {
this.fillBuffer_();
}
if (this.checkBufferTimeout_) {
window.clearTimeout(this.checkBufferTimeout_);
}
this.checkBufferTimeout_ = window.setTimeout(this.monitorBufferTick_.bind(this),
CHECK_BUFFER_DELAY);
}
/**
* fill the buffer with segements unless the sourceBuffers are
* currently updating
*
* Note: this function should only ever be called by monitorBuffer_
* and never directly
*
* @private
*/
fillBuffer_() {
if (this.sourceUpdater_.updating()) {
return;
}
if (!this.syncPoint_) {
this.syncPoint_ = this.syncController_.getSyncPoint(this.playlist_,
this.duration_(),
this.currentTimeline_,
this.currentTime_());
}
// see if we need to begin loading immediately
let segmentInfo = this.checkBuffer_(this.buffered_(),
this.playlist_,
this.mediaIndex,
this.hasPlayed_(),
this.currentTime_(),
this.syncPoint_);
if (!segmentInfo) {
return;
}
let isEndOfStream = detectEndOfStream(this.playlist_,
this.mediaSource_,
segmentInfo.mediaIndex);
if (isEndOfStream) {
this.endOfStream();
return;
}
if (segmentInfo.mediaIndex === this.playlist_.segments.length - 1 &&
this.mediaSource_.readyState === 'ended' &&
!this.seeking_()) {
return;
}
// We will need to change timestampOffset of the sourceBuffer if either of
// the following conditions are true:
// - The segment.timeline !== this.currentTimeline
// (we are crossing a discontinuity somehow)
// - The "timestampOffset" for the start of this segment is less than
// the currently set timestampOffset
if (segmentInfo.timeline !== this.currentTimeline_ ||
((segmentInfo.startOfSegment !== null) &&
segmentInfo.startOfSegment < this.sourceUpdater_.timestampOffset())) {
this.syncController_.reset();
segmentInfo.timestampOffset = segmentInfo.startOfSegment;
}
this.loadSegment_(segmentInfo);
}
/**
* Determines what segment request should be made, given current playback
* state.
*
* @param {TimeRanges} buffered - the state of the buffer
* @param {Object} playlist - the playlist object to fetch segments from
* @param {Number} mediaIndex - the previous mediaIndex fetched or null
* @param {Boolean} hasPlayed - a flag indicating whether we have played or not
* @param {Number} currentTime - the playback position in seconds
* @param {Object} syncPoint - a segment info object that describes the
* @returns {Object} a segment request object that describes the segment to load
*/
checkBuffer_(buffered, playlist, mediaIndex, hasPlayed, currentTime, syncPoint) {
let lastBufferedEnd = 0;
let startOfSegment;
if (buffered.length) {
lastBufferedEnd = buffered.end(buffered.length - 1);
}
let bufferedTime = Math.max(0, lastBufferedEnd - currentTime);
if (!playlist.segments.length) {
return null;
}
// if there is plenty of content buffered, and the video has
// been played before relax for awhile
if (bufferedTime >= this.goalBufferLength_()) {
return null;
}
// if the video has not yet played once, and we already have
// one segment downloaded do nothing
if (!hasPlayed && bufferedTime >= 1) {
return null;
}
this.logger_('checkBuffer_',
'mediaIndex:', mediaIndex,
'hasPlayed:', hasPlayed,
'currentTime:', currentTime,
'syncPoint:', syncPoint,
'fetchAtBuffer:', this.fetchAtBuffer_,
'bufferedTime:', bufferedTime);
// When the syncPoint is null, there is no way of determining a good
// conservative segment index to fetch from
// The best thing to do here is to get the kind of sync-point data by
// making a request
if (syncPoint === null) {
mediaIndex = this.getSyncSegmentCandidate_(playlist);
this.logger_('getSync', 'mediaIndex:', mediaIndex);
return this.generateSegmentInfo_(playlist, mediaIndex, null, true);
}
// Under normal playback conditions fetching is a simple walk forward
if (mediaIndex !== null) {
this.logger_('walkForward', 'mediaIndex:', mediaIndex + 1);
let segment = playlist.segments[mediaIndex];
if (segment && segment.end) {
startOfSegment = segment.end;
} else {
startOfSegment = lastBufferedEnd;
}
return this.generateSegmentInfo_(playlist, mediaIndex + 1, startOfSegment, false);
}
// There is a sync-point but the lack of a mediaIndex indicates that
// we need to make a good conservative guess about which segment to
// fetch
if (this.fetchAtBuffer_) {
// Find the segment containing the end of the buffer
let mediaSourceInfo = Playlist.getMediaInfoForTime(playlist,
lastBufferedEnd,
syncPoint.segmentIndex,
syncPoint.time);
mediaIndex = mediaSourceInfo.mediaIndex;
startOfSegment = mediaSourceInfo.startTime;
} else {
// Find the segment containing currentTime
let mediaSourceInfo = Playlist.getMediaInfoForTime(playlist,
currentTime,
syncPoint.segmentIndex,
syncPoint.time);
mediaIndex = mediaSourceInfo.mediaIndex;
startOfSegment = mediaSourceInfo.startTime;
}
this.logger_('getMediaIndexForTime',
'mediaIndex:', mediaIndex,
'startOfSegment:', startOfSegment);
return this.generateSegmentInfo_(playlist, mediaIndex, startOfSegment, false);
}
/**
* The segment loader has no recourse except to fetch a segment in the
* current playlist and use the internal timestamps in that segment to
* generate a syncPoint. This function returns a good candidate index
* for that process.
*
* @param {Object} playlist - the playlist object to look for a
* @returns {Number} An index of a segment from the playlist to load
*/
getSyncSegmentCandidate_(playlist) {
if (this.currentTimeline_ === -1) {
return 0;
}
let segmentIndexArray = playlist.segments
.map((s, i) => {
return {
timeline: s.timeline,
segmentIndex: i
};
}).filter(s => s.timeline === this.currentTimeline_);
if (segmentIndexArray.length) {
return segmentIndexArray[Math.min(segmentIndexArray.length - 1, 1)].segmentIndex;
}
return Math.max(playlist.segments.length - 1, 0);
}
generateSegmentInfo_(playlist, mediaIndex, startOfSegment, isSyncRequest) {
if (mediaIndex < 0 || mediaIndex >= playlist.segments.length) {
return null;
}
let segment = playlist.segments[mediaIndex];
return {
requestId: 'segment-loader-' + Math.random(),
// resolve the segment URL relative to the playlist
uri: segment.resolvedUri,
// the segment's mediaIndex at the time it was requested
mediaIndex,
// whether or not to update the SegmentLoader's state with this
// segment's mediaIndex
isSyncRequest,
startOfSegment,
// the segment's playlist
playlist,
// unencrypted bytes of the segment
bytes: null,
// when a key is defined for this segment, the encrypted bytes
encryptedBytes: null,
// The target timestampOffset for this segment when we append it
// to the source buffer
timestampOffset: null,
// The timeline that the segment is in
timeline: segment.timeline,
// The expected duration of the segment in seconds
duration: segment.duration,
// retain the segment in case the playlist updates while doing an async process
segment
};
}
/**
* Determines if the network has enough bandwidth to complete the current segment
* request in a timely manner. If not, the request will be aborted early and bandwidth
* updated to trigger a playlist switch.
*
* @param {Object} stats
* Object containing stats about the request timing and size
* @return {Boolean} True if the request was aborted, false otherwise
* @private
*/
abortRequestEarly_(stats) {
if (this.hls_.tech_.paused() ||
// Don't abort if the current playlist is on the lowestEnabledRendition
// TODO: Replace using timeout with a boolean indicating whether this playlist is
// the lowestEnabledRendition.
!this.xhrOptions_.timeout ||
// Don't abort if we have no bandwidth information to estimate segment sizes
!(this.playlist_.attributes.BANDWIDTH)) {
return false;
}
// Wait at least 1 second since the first byte of data has been received before
// using the calculated bandwidth from the progress event to allow the bitrate
// to stabilize
if (Date.now() - (stats.firstBytesReceivedAt || Date.now()) < 1000) {
return false;
}
const currentTime = this.currentTime_();
const measuredBandwidth = stats.bandwidth;
const segmentDuration = this.pendingSegment_.duration;
const requestTimeRemaining =
Playlist.estimateSegmentRequestTime(segmentDuration,
measuredBandwidth,
this.playlist_,
stats.bytesReceived);
// Subtract 1 from the timeUntilRebuffer so we still consider an early abort
// if we are only left with less than 1 second when the request completes.
// A negative timeUntilRebuffering indicates we are already rebuffering
const timeUntilRebuffer = timeUntilRebuffer_(this.buffered_(),
currentTime,
this.hls_.tech_.playbackRate()) - 1;
// Only consider aborting early if the estimated time to finish the download
// is larger than the estimated time until the player runs out of forward buffer
if (requestTimeRemaining <= timeUntilRebuffer) {
return false;
}
const switchCandidate = minRebufferMaxBandwidthSelector({
master: this.hls_.playlists.master,
currentTime,
bandwidth: measuredBandwidth,
duration: this.duration_(),
segmentDuration,
timeUntilRebuffer,
currentTimeline: this.currentTimeline_,
syncController: this.syncController_
});
if (!switchCandidate) {
return;
}
const rebufferingImpact = requestTimeRemaining - timeUntilRebuffer;
const timeSavedBySwitching = rebufferingImpact - switchCandidate.rebufferingImpact;
let minimumTimeSaving = 0.5;
// If we are already rebuffering, increase the amount of variance we add to the
// potential round trip time of the new request so that we are not too aggressive
// with switching to a playlist that might save us a fraction of a second.
if (timeUntilRebuffer <= TIME_FUDGE_FACTOR) {
minimumTimeSaving = 1;
}
if (!switchCandidate.playlist ||
switchCandidate.playlist.uri === this.playlist_.uri ||
timeSavedBySwitching < minimumTimeSaving) {
return false;
}
// set the bandwidth to that of the desired playlist being sure to scale by
// BANDWIDTH_VARIANCE and add one so the playlist selector does not exclude it
// don't trigger a bandwidthupdate as the bandwidth is artifial
this.bandwidth =
switchCandidate.playlist.attributes.BANDWIDTH * Config.BANDWIDTH_VARIANCE + 1;
this.abort();
this.trigger('earlyabort');
return true;
}
/**
* XHR `progress` event handler
*
* @param {Event}
* The XHR `progress` event
* @param {Object} simpleSegment
* A simplified segment object copy
* @private
*/
handleProgress_(event, simpleSegment) {
if (!this.pendingSegment_ ||
simpleSegment.requestId !== this.pendingSegment_.requestId ||
this.abortRequestEarly_(simpleSegment.stats)) {
return;
}
this.trigger('progress');
}
/**
* load a specific segment from a request into the buffer
*
* @private
*/
loadSegment_(segmentInfo) {
this.state = 'WAITING';
this.pendingSegment_ = segmentInfo;
this.trimBackBuffer_(segmentInfo);
segmentInfo.abortRequests = mediaSegmentRequest(this.hls_.xhr,
this.xhrOptions_,
this.decrypter_,
this.createSimplifiedSegmentObj_(segmentInfo),
// progress callback
this.handleProgress_.bind(this),
this.segmentRequestFinished_.bind(this));
}
/**
* trim the back buffer so that we don't have too much data
* in the source buffer
*
* @private
*
* @param {Object} segmentInfo - the current segment
*/
trimBackBuffer_(segmentInfo) {
const removeToTime = safeBackBufferTrimTime(this.seekable_(),
this.currentTime_(),
this.playlist_.targetDuration || 10);
// 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 (removeToTime > 0) {
this.remove(0, removeToTime);
}
}
/**
* created a simplified copy of the segment object with just the
* information necessary to perform the XHR and decryption
*
* @private
*
* @param {Object} segmentInfo - the current segment
* @returns {Object} a simplified segment object copy
*/
createSimplifiedSegmentObj_(segmentInfo) {
const segment = segmentInfo.segment;
const simpleSegment = {
resolvedUri: segment.resolvedUri,
byterange: segment.byterange,
requestId: segmentInfo.requestId
};
if (segment.key) {
// if the media sequence is greater than 2^32, the IV will be incorrect
// assuming 10s segments, that would be about 1300 years
const iv = segment.key.iv || new Uint32Array([
0, 0, 0, segmentInfo.mediaIndex + segmentInfo.playlist.mediaSequence
]);
simpleSegment.key = {
resolvedUri: segment.key.resolvedUri,
iv
};
}
if (segment.map) {
simpleSegment.map = this.initSegment(segment.map);
}
return simpleSegment;
}
/**
* Handle the callback from the segmentRequest function and set the
* associated SegmentLoader state and errors if necessary
*
* @private
*/
segmentRequestFinished_(error, simpleSegment) {
// every request counts as a media request even if it has been aborted
// or canceled due to a timeout
this.mediaRequests += 1;
if (simpleSegment.stats) {
this.mediaBytesTransferred += simpleSegment.stats.bytesReceived;
this.mediaTransferDuration += simpleSegment.stats.roundTripTime;
}
// The request was aborted and the SegmentLoader has already been reset
if (!this.pendingSegment_) {
this.mediaRequestsAborted += 1;
return;
}
// the request was aborted and the SegmentLoader has already started
// another request. this can happen when the timeout for an aborted
// request triggers due to a limitation in the XHR library
// do not count this as any sort of request or we risk double-counting
if (simpleSegment.requestId !== this.pendingSegment_.requestId) {
return;
}
// an error occurred from the active pendingSegment_ so reset everything
if (error) {
this.pendingSegment_ = null;
this.state = 'READY';
// the requests were aborted just record the aborted stat and exit
// this is not a true error condition and nothing corrective needs
// to be done
if (error.code === REQUEST_ERRORS.ABORTED) {
this.mediaRequestsAborted += 1;
return;
}
this.pause();
// the error is really just that at least one of the requests timed-out
// set the bandwidth to a very low value and trigger an ABR switch to
// take emergency action
if (error.code === REQUEST_ERRORS.TIMEOUT) {
this.mediaRequestsTimedout += 1;
this.bandwidth = 1;
this.roundTrip = NaN;
this.trigger('bandwidthupdate');
return;
}
// if control-flow has arrived here, then the error is real
// emit an error event to blacklist the current playlist
this.mediaRequestsErrored += 1;
this.error(error);
this.trigger('error');
return;
}
// the response was a success so set any bandwidth stats the request
// generated for ABR purposes
this.bandwidth = simpleSegment.stats.bandwidth;
this.roundTrip = simpleSegment.stats.roundTripTime;
// if this request included an initialization segment, save that data
// to the initSegment cache
if (simpleSegment.map) {
simpleSegment.map = this.initSegment(simpleSegment.map, true);
}
this.processSegmentResponse_(simpleSegment);
}
/**
* Move any important data from the simplified segment object
* back to the real segment object for future phases
*
* @private
*/
processSegmentResponse_(simpleSegment) {
const segmentInfo = this.pendingSegment_;
segmentInfo.bytes = simpleSegment.bytes;
if (simpleSegment.map) {
segmentInfo.segment.map.bytes = simpleSegment.map.bytes;
}
segmentInfo.endOfAllRequests = simpleSegment.endOfAllRequests;
this.handleSegment_();
}
/**
* append a decrypted segement to the SourceBuffer through a SourceUpdater
*
* @private
*/
handleSegment_() {
if (!this.pendingSegment_) {
this.state = 'READY';
return;
}
const segmentInfo = this.pendingSegment_;
const segment = segmentInfo.segment;
const timingInfo = this.syncController_.probeSegmentInfo(segmentInfo);
// When we have our first timing info, determine what media types this loader is
// dealing with. Although we're maintaining extra state, it helps to preserve the
// separation of segment loader from the actual source buffers.
if (typeof this.startingMedia_ === 'undefined' &&
timingInfo &&
// Guard against cases where we're not getting timing info at all until we are
// certain that all streams will provide it.
(timingInfo.containsAudio || timingInfo.containsVideo)) {
this.startingMedia_ = {
containsAudio: timingInfo.containsAudio,
containsVideo: timingInfo.containsVideo
};
}
const illegalMediaSwitchError =
illegalMediaSwitch(this.loaderType_, this.startingMedia_, timingInfo);
if (illegalMediaSwitchError) {
this.error({
message: illegalMediaSwitchError,
blacklistDuration: Infinity
});
this.trigger('error');
return;
}
if (segmentInfo.isSyncRequest) {
this.trigger('syncinfoupdate');
this.pendingSegment_ = null;
this.state = 'READY';
return;
}
if (segmentInfo.timestampOffset !== null &&
segmentInfo.timestampOffset !== this.sourceUpdater_.timestampOffset()) {
this.sourceUpdater_.timestampOffset(segmentInfo.timestampOffset);
// fired when a timestamp offset is set in HLS (can also identify discontinuities)
this.trigger('timestampoffset');
}
const timelineMapping = this.syncController_.mappingForTimeline(segmentInfo.timeline);
if (timelineMapping !== null) {
this.trigger({
type: 'segmenttimemapping',
mapping: timelineMapping
});
}
this.state = 'APPENDING';
// if the media initialization segment is changing, append it
// before the content segment
if (segment.map) {
const initId = initSegmentId(segment.map);
if (!this.activeInitSegmentId_ ||
this.activeInitSegmentId_ !== initId) {
const initSegment = this.initSegment(segment.map);
this.sourceUpdater_.appendBuffer(initSegment.bytes, () => {
this.activeInitSegmentId_ = initId;
});
}
}
segmentInfo.byteLength = segmentInfo.bytes.byteLength;
if (typeof segment.start === 'number' && typeof segment.end === 'number') {
this.mediaSecondsLoaded += segment.end - segment.start;
} else {
this.mediaSecondsLoaded += segment.duration;
}
this.sourceUpdater_.appendBuffer(segmentInfo.bytes,
this.handleUpdateEnd_.bind(this));
}
/**
* callback to run when appendBuffer is finished. detects if we are
* in a good state to do things with the data we got, or if we need
* to wait for more
*
* @private
*/
handleUpdateEnd_() {
this.logger_('handleUpdateEnd_', 'segmentInfo:', this.pendingSegment_);
if (!this.pendingSegment_) {
this.state = 'READY';
if (!this.paused()) {
this.monitorBuffer_();
}
return;
}
const segmentInfo = this.pendingSegment_;
const segment = segmentInfo.segment;
const isWalkingForward = this.mediaIndex !== null;
this.pendingSegment_ = null;
this.recordThroughput_(segmentInfo);
this.addSegmentMetadataCue_(segmentInfo);
this.state = 'READY';
this.mediaIndex = segmentInfo.mediaIndex;
this.fetchAtBuffer_ = true;
this.currentTimeline_ = segmentInfo.timeline;
// We must update the syncinfo to recalculate the seekable range before
// the following conditional otherwise it may consider this a bad "guess"
// and attempt to resync when the post-update seekable window and live
// point would mean that this was the perfect segment to fetch
this.trigger('syncinfoupdate');
// If we previously appended a segment that ends more than 3 targetDurations before
// the currentTime_ that means that our conservative guess was too conservative.
// In that case, reset the loader state so that we try to use any information gained
// from the previous request to create a new, more accurate, sync-point.
if (segment.end &&
this.currentTime_() - segment.end > segmentInfo.playlist.targetDuration * 3) {
this.resetEverything();
return;
}
// Don't do a rendition switch unless we have enough time to get a sync segment
// and conservatively guess
if (isWalkingForward) {
this.trigger('bandwidthupdate');
}
this.trigger('progress');
// any time an update finishes and the last segment is in the
// buffer, end the stream. this ensures the "ended" event will
// fire if playback reaches that point.
const isEndOfStream = detectEndOfStream(segmentInfo.playlist,
this.mediaSource_,
segmentInfo.mediaIndex + 1);
if (isEndOfStream) {
this.endOfStream();
}
if (!this.paused()) {
this.monitorBuffer_();
}
}
/**
* Records the current throughput of the decrypt, transmux, and append
* portion of the semgment pipeline. `throughput.rate` is a the cumulative
* moving average of the throughput. `throughput.count` is the number of
* data points in the average.
*
* @private
* @param {Object} segmentInfo the object returned by loadSegment
*/
recordThroughput_(segmentInfo) {
const rate = this.throughput.rate;
// Add one to the time to ensure that we don't accidentally attempt to divide
// by zero in the case where the throughput is ridiculously high
const segmentProcessingTime =
Date.now() - segmentInfo.endOfAllRequests + 1;
// Multiply by 8000 to convert from bytes/millisecond to bits/second
const segmentProcessingThroughput =
Math.floor((segmentInfo.byteLength / segmentProcessingTime) * 8 * 1000);
// This is just a cumulative moving average calculation:
// newAvg = oldAvg + (sample - oldAvg) / (sampleCount + 1)
this.throughput.rate +=
(segmentProcessingThroughput - rate) / (++this.throughput.count);
}
/**
* A debugging logger noop that is set to console.log only if debugging
* is enabled globally
*
* @private
*/
logger_() {}
/**
* Adds a cue to the segment-metadata track with some metadata information about the
* segment
*
* @private
* @param {Object} segmentInfo
* the object returned by loadSegment
* @method addSegmentMetadataCue_
*/
addSegmentMetadataCue_(segmentInfo) {
if (!this.segmentMetadataTrack_) {
return;
}
const segment = segmentInfo.segment;
const start = segment.start;
const end = segment.end;
// Do not try adding the cue if the start and end times are invalid.
if (!finite(start) || !finite(end)) {
return;
}
removeCuesFromTrack(start, end, this.segmentMetadataTrack_);
const Cue = window.WebKitDataCue || window.VTTCue;
const value = {
bandwidth: segmentInfo.playlist.attributes.BANDWIDTH,
resolution: segmentInfo.playlist.attributes.RESOLUTION,
codecs: segmentInfo.playlist.attributes.CODECS,
byteLength: segmentInfo.byteLength,
uri: segmentInfo.uri,
timeline: segmentInfo.timeline,
playlist: segmentInfo.playlist.uri,
start,
end
};
const data = JSON.stringify(value);
const cue = new Cue(start, end, data);
// Attach the metadata to the value property of the cue to keep consistency between
// the differences of WebKitDataCue in safari and VTTCue in other browsers
cue.value = value;
this.segmentMetadataTrack_.addCue(cue);
}
}