You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1146 lines
34 KiB
1146 lines
34 KiB
/**
|
|
* @file segment-loader.js
|
|
*/
|
|
import {getMediaInfoForTime_ as getMediaInfoForTime} from './playlist';
|
|
import videojs from 'video.js';
|
|
import SourceUpdater from './source-updater';
|
|
import Config from './config';
|
|
import window from 'global/window';
|
|
import { createTransferableMessage } from './bin-utils';
|
|
|
|
// 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) {
|
|
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;
|
|
};
|
|
|
|
/**
|
|
* Turns segment byterange into a string suitable for use in
|
|
* HTTP Range requests
|
|
*/
|
|
const byterangeStr = function(byterange) {
|
|
let byterangeStart;
|
|
let 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.
|
|
*/
|
|
const segmentXhrHeaders = function(segment) {
|
|
let headers = {};
|
|
|
|
if ('byterange' in segment) {
|
|
headers.Range = byterangeStr(segment.byterange);
|
|
}
|
|
return headers;
|
|
};
|
|
|
|
/**
|
|
* Returns a unique string identifier for a media initialization
|
|
* segment.
|
|
*/
|
|
const initSegmentId = function(initSegment) {
|
|
let byterange = initSegment.byterange || {
|
|
length: Infinity,
|
|
offset: 0
|
|
};
|
|
|
|
return [
|
|
byterange.length, byterange.offset, initSegment.resolvedUri
|
|
].join(',');
|
|
};
|
|
|
|
/**
|
|
* 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(options) {
|
|
super();
|
|
// check pre-conditions
|
|
if (!options) {
|
|
throw new TypeError('Initialization options are required');
|
|
}
|
|
if (typeof options.currentTime !== 'function') {
|
|
throw new TypeError('No currentTime getter specified');
|
|
}
|
|
if (!options.mediaSource) {
|
|
throw new TypeError('No MediaSource specified');
|
|
}
|
|
let settings = videojs.mergeOptions(videojs.options.hls, options);
|
|
|
|
// 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.setCurrentTime_ = settings.setCurrentTime;
|
|
this.mediaSource_ = settings.mediaSource;
|
|
this.hls_ = settings.hls;
|
|
this.loaderType_ = settings.loaderType;
|
|
|
|
// private instance variables
|
|
this.checkBufferTimeout_ = null;
|
|
this.error_ = void 0;
|
|
this.currentTimeline_ = -1;
|
|
this.xhr_ = null;
|
|
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'));
|
|
|
|
// ...for determining the fetch location
|
|
this.fetchAtBuffer_ = false;
|
|
|
|
if (settings.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.mediaTransferDuration = 0;
|
|
this.mediaSecondsLoaded = 0;
|
|
}
|
|
|
|
/**
|
|
* dispose of the SegmentLoader and reset to the default state
|
|
*/
|
|
dispose() {
|
|
this.state = 'DISPOSED';
|
|
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_();
|
|
|
|
// don't wait for buffer check timeouts to begin fetching the
|
|
// next segment
|
|
if (!this.paused()) {
|
|
this.state = 'READY';
|
|
this.monitorBuffer_();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* abort all pending xhr requests and null any pending segements
|
|
*
|
|
* @private
|
|
*/
|
|
abort_() {
|
|
if (this.xhr_) {
|
|
this.xhr_.abort();
|
|
}
|
|
|
|
// 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_;
|
|
}
|
|
|
|
/**
|
|
* 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.mimeType_) {
|
|
return this.init_();
|
|
}
|
|
|
|
// if we're in the middle of processing a segment already, don't
|
|
// kick off an additional segment request
|
|
if (!this.sourceUpdater_ ||
|
|
(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.mimeType_ && this.state === 'INIT' && !this.paused()) {
|
|
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.playlist_ &&
|
|
this.state === 'INIT' &&
|
|
!this.paused()) {
|
|
this.init_();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete all the buffered data and reset the SegmentLoader
|
|
*/
|
|
resetEverything() {
|
|
this.resetLoader();
|
|
this.remove(0, Infinity);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* (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.mediaSource_.duration,
|
|
this.currentTimeline_,
|
|
this.currentTime_());
|
|
}
|
|
|
|
// see if we need to begin loading immediately
|
|
let segmentInfo = this.checkBuffer_(this.sourceUpdater_.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.mediaSource_.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 >= Config.GOAL_BUFFER_LENGTH) {
|
|
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 = getMediaInfoForTime(playlist,
|
|
lastBufferedEnd,
|
|
syncPoint.segmentIndex,
|
|
syncPoint.time);
|
|
|
|
mediaIndex = mediaSourceInfo.mediaIndex;
|
|
startOfSegment = mediaSourceInfo.startTime;
|
|
} else {
|
|
// Find the segment containing currentTime
|
|
let mediaSourceInfo = 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 {
|
|
// 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
|
|
};
|
|
}
|
|
|
|
/**
|
|
* load a specific segment from a request into the buffer
|
|
*
|
|
* @private
|
|
*/
|
|
loadSegment_(segmentInfo) {
|
|
let segment;
|
|
let keyXhr;
|
|
let initSegmentXhr;
|
|
let segmentXhr;
|
|
let removeToTime = 0;
|
|
|
|
removeToTime = this.trimBuffer_(segmentInfo);
|
|
|
|
if (removeToTime > 0) {
|
|
this.sourceUpdater_.remove(0, removeToTime);
|
|
}
|
|
|
|
segment = segmentInfo.segment;
|
|
|
|
// optionally, request the decryption key
|
|
if (segment.key) {
|
|
let keyRequestOptions = videojs.mergeOptions(this.xhrOptions_, {
|
|
uri: segment.key.resolvedUri,
|
|
responseType: 'arraybuffer'
|
|
});
|
|
|
|
keyXhr = this.hls_.xhr(keyRequestOptions, this.handleResponse_.bind(this));
|
|
}
|
|
|
|
// optionally, request the associated media init segment
|
|
if (segment.map &&
|
|
!this.initSegments_[initSegmentId(segment.map)]) {
|
|
let initSegmentOptions = videojs.mergeOptions(this.xhrOptions_, {
|
|
uri: segment.map.resolvedUri,
|
|
responseType: 'arraybuffer',
|
|
headers: segmentXhrHeaders(segment.map)
|
|
});
|
|
|
|
initSegmentXhr = this.hls_.xhr(initSegmentOptions,
|
|
this.handleResponse_.bind(this));
|
|
}
|
|
this.pendingSegment_ = segmentInfo;
|
|
|
|
let segmentRequestOptions = videojs.mergeOptions(this.xhrOptions_, {
|
|
uri: segmentInfo.uri,
|
|
responseType: 'arraybuffer',
|
|
headers: segmentXhrHeaders(segment)
|
|
});
|
|
|
|
segmentXhr = this.hls_.xhr(segmentRequestOptions, this.handleResponse_.bind(this));
|
|
segmentXhr.addEventListener('progress', (event) => {
|
|
this.trigger(event);
|
|
});
|
|
|
|
this.xhr_ = {
|
|
keyXhr,
|
|
initSegmentXhr,
|
|
segmentXhr,
|
|
abort() {
|
|
if (this.segmentXhr) {
|
|
// Prevent error handler from running.
|
|
this.segmentXhr.onreadystatechange = null;
|
|
this.segmentXhr.abort();
|
|
this.segmentXhr = null;
|
|
}
|
|
if (this.initSegmentXhr) {
|
|
// Prevent error handler from running.
|
|
this.initSegmentXhr.onreadystatechange = null;
|
|
this.initSegmentXhr.abort();
|
|
this.initSegmentXhr = null;
|
|
}
|
|
if (this.keyXhr) {
|
|
// Prevent error handler from running.
|
|
this.keyXhr.onreadystatechange = null;
|
|
this.keyXhr.abort();
|
|
this.keyXhr = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
this.state = 'WAITING';
|
|
}
|
|
|
|
/**
|
|
* trim the back buffer so we only remove content
|
|
* on segment boundaries
|
|
*
|
|
* @private
|
|
*
|
|
* @param {Object} segmentInfo - the current segment
|
|
* @returns {Number} removeToTime - the end point in time, in seconds
|
|
* that the the buffer should be trimmed.
|
|
*/
|
|
trimBuffer_(segmentInfo) {
|
|
let seekable = this.seekable_();
|
|
let currentTime = this.currentTime_();
|
|
let removeToTime;
|
|
|
|
// 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 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) {
|
|
return seekable.start(0);
|
|
}
|
|
|
|
removeToTime = currentTime - 60;
|
|
|
|
return removeToTime;
|
|
}
|
|
|
|
/**
|
|
* triggered when a segment response is received
|
|
*
|
|
* @private
|
|
*/
|
|
handleResponse_(error, request) {
|
|
let segmentInfo;
|
|
let segment;
|
|
let view;
|
|
|
|
// timeout of previously aborted request
|
|
if (!this.xhr_ ||
|
|
(request !== this.xhr_.segmentXhr &&
|
|
request !== this.xhr_.keyXhr &&
|
|
request !== this.xhr_.initSegmentXhr)) {
|
|
return;
|
|
}
|
|
|
|
segmentInfo = this.pendingSegment_;
|
|
segment = segmentInfo.segment;
|
|
|
|
// if a request times out, reset bandwidth tracking
|
|
if (request.timedout) {
|
|
this.abort_();
|
|
this.bandwidth = 1;
|
|
this.roundTrip = NaN;
|
|
this.state = 'READY';
|
|
return this.trigger('progress');
|
|
}
|
|
|
|
// trigger an event for other errors
|
|
if (!request.aborted && error) {
|
|
// abort will clear xhr_
|
|
let keyXhrRequest = this.xhr_.keyXhr;
|
|
|
|
this.abort_();
|
|
this.error({
|
|
status: request.status,
|
|
message: request === keyXhrRequest ?
|
|
'HLS key request error at URL: ' + segment.key.uri :
|
|
'HLS segment request error at URL: ' + segmentInfo.uri,
|
|
code: 2,
|
|
xhr: request
|
|
});
|
|
this.state = 'READY';
|
|
this.pause();
|
|
return this.trigger('error');
|
|
}
|
|
|
|
// stop processing if the request was aborted
|
|
if (!request.response) {
|
|
this.abort_();
|
|
return;
|
|
}
|
|
|
|
if (request === this.xhr_.segmentXhr) {
|
|
// the segment request is no longer outstanding
|
|
this.xhr_.segmentXhr = null;
|
|
segmentInfo.startOfAppend = Date.now();
|
|
|
|
// calculate the download bandwidth based on segment request
|
|
this.roundTrip = request.roundTripTime;
|
|
this.bandwidth = request.bandwidth;
|
|
|
|
// update analytics stats
|
|
this.mediaBytesTransferred += request.bytesReceived || 0;
|
|
this.mediaRequests += 1;
|
|
this.mediaTransferDuration += request.roundTripTime || 0;
|
|
|
|
if (segment.key) {
|
|
segmentInfo.encryptedBytes = new Uint8Array(request.response);
|
|
} else {
|
|
segmentInfo.bytes = new Uint8Array(request.response);
|
|
}
|
|
}
|
|
|
|
if (request === this.xhr_.keyXhr) {
|
|
// the key request is no longer outstanding
|
|
this.xhr_.keyXhr = null;
|
|
|
|
if (request.response.byteLength !== 16) {
|
|
this.abort_();
|
|
this.error({
|
|
status: request.status,
|
|
message: 'Invalid HLS key at URL: ' + segment.key.uri,
|
|
code: 2,
|
|
xhr: request
|
|
});
|
|
this.state = 'READY';
|
|
this.pause();
|
|
return this.trigger('error');
|
|
}
|
|
|
|
view = new DataView(request.response);
|
|
segment.key.bytes = new Uint32Array([
|
|
view.getUint32(0),
|
|
view.getUint32(4),
|
|
view.getUint32(8),
|
|
view.getUint32(12)
|
|
]);
|
|
|
|
// if the media sequence is greater than 2^32, the IV will be incorrect
|
|
// assuming 10s segments, that would be about 1300 years
|
|
segment.key.iv = segment.key.iv || new Uint32Array([
|
|
0, 0, 0, segmentInfo.mediaIndex + segmentInfo.playlist.mediaSequence
|
|
]);
|
|
}
|
|
|
|
if (request === this.xhr_.initSegmentXhr) {
|
|
// the init segment request is no longer outstanding
|
|
this.xhr_.initSegmentXhr = null;
|
|
segment.map.bytes = new Uint8Array(request.response);
|
|
this.initSegments_[initSegmentId(segment.map)] = segment.map;
|
|
}
|
|
|
|
if (!this.xhr_.segmentXhr && !this.xhr_.keyXhr && !this.xhr_.initSegmentXhr) {
|
|
this.xhr_ = null;
|
|
this.processResponse_();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decrypt the segment that is being loaded if necessary
|
|
*
|
|
* @private
|
|
*/
|
|
processResponse_() {
|
|
if (!this.pendingSegment_) {
|
|
this.state = 'READY';
|
|
return;
|
|
}
|
|
|
|
this.state = 'DECRYPTING';
|
|
|
|
let segmentInfo = this.pendingSegment_;
|
|
let segment = segmentInfo.segment;
|
|
|
|
if (segment.key) {
|
|
// this is an encrypted segment
|
|
// incrementally decrypt the segment
|
|
this.decrypter_.postMessage(createTransferableMessage({
|
|
source: this.loaderType_,
|
|
encrypted: segmentInfo.encryptedBytes,
|
|
key: segment.key.bytes,
|
|
iv: segment.key.iv
|
|
}), [
|
|
segmentInfo.encryptedBytes.buffer,
|
|
segment.key.bytes.buffer
|
|
]);
|
|
} else {
|
|
this.handleSegment_();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles response from the decrypter and attaches the decrypted bytes to the pending
|
|
* segment
|
|
*
|
|
* @param {Object} data
|
|
* Response from decrypter
|
|
* @method handleDecrypted_
|
|
*/
|
|
handleDecrypted_(data) {
|
|
const segmentInfo = this.pendingSegment_;
|
|
const decrypted = data.decrypted;
|
|
|
|
if (segmentInfo) {
|
|
segmentInfo.bytes = new Uint8Array(decrypted.bytes,
|
|
decrypted.byteOffset,
|
|
decrypted.byteLength);
|
|
}
|
|
this.handleSegment_();
|
|
}
|
|
|
|
/**
|
|
* append a decrypted segement to the SourceBuffer through a SourceUpdater
|
|
*
|
|
* @private
|
|
*/
|
|
handleSegment_() {
|
|
if (!this.pendingSegment_) {
|
|
this.state = 'READY';
|
|
return;
|
|
}
|
|
|
|
this.state = 'APPENDING';
|
|
|
|
let segmentInfo = this.pendingSegment_;
|
|
let segment = segmentInfo.segment;
|
|
|
|
this.syncController_.probeSegmentInfo(segmentInfo);
|
|
|
|
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);
|
|
}
|
|
|
|
// if the media initialization segment is changing, append it
|
|
// before the content segment
|
|
if (segment.map) {
|
|
let initId = initSegmentId(segment.map);
|
|
|
|
if (!this.activeInitSegmentId_ ||
|
|
this.activeInitSegmentId_ !== initId) {
|
|
let initSegment = this.initSegments_[initId];
|
|
|
|
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;
|
|
}
|
|
|
|
let segmentInfo = this.pendingSegment_;
|
|
let segment = segmentInfo.segment;
|
|
let isWalkingForward = this.mediaIndex !== null;
|
|
|
|
this.pendingSegment_ = null;
|
|
this.recordThroughput_(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 the SegmentLoader is already walking forward
|
|
if (isWalkingForward) {
|
|
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.
|
|
let isEndOfStream = detectEndOfStream(segmentInfo.playlist,
|
|
this.mediaSource_,
|
|
this.mediaIndex + 1);
|
|
|
|
if (isEndOfStream) {
|
|
this.mediaSource_.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) {
|
|
let 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
|
|
let segmentProcessingTime =
|
|
Date.now() - segmentInfo.startOfAppend + 1;
|
|
// Multiply by 8000 to convert from bytes/millisecond to bits/second
|
|
let 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_() {}
|
|
}
|