@videojs/http-streaming
Version:
Play back HLS and DASH with Video.js, even where it's not natively supported
1,471 lines (1,303 loc) • 101 kB
JavaScript
/**
* @file segment-loader.js
*/
import Playlist from './playlist';
import videojs from 'video.js';
import Config from './config';
import window from 'global/window';
import { initSegmentId, segmentKeyId } from './bin-utils';
import { mediaSegmentRequest, REQUEST_ERRORS } from './media-segment-request';
import segmentTransmuxer from './segment-transmuxer';
import { TIME_FUDGE_FACTOR, timeUntilRebuffer as timeUntilRebuffer_ } from './ranges';
import { minRebufferMaxBandwidthSelector } from './playlist-selectors';
import logger from './util/logger';
import { concatSegments } from './util/segment';
import {
createCaptionsTrackIfNotExists,
createMetadataTrackIfNotExists,
addMetadata,
addCaptionData,
removeCuesFromTrack
} from './util/text-tracks';
import { gopsSafeToAlignWith, removeGopBuffer, updateGopBuffer } from './util/gops';
import shallowEqual from './util/shallow-equal.js';
// in ms
const CHECK_BUFFER_DELAY = 500;
const finite = (num) => typeof num === 'number' && isFinite(num);
// With most content hovering around 30fps, if a segment has a duration less than a half
// frame at 30fps or one frame at 60fps, the bandwidth and throughput calculations will
// not accurately reflect the rest of the content.
const MIN_SEGMENT_DURATION_TO_SAVE_STATS = 1 / 60;
export const illegalMediaSwitch = (loaderType, startingMedia, trackInfo) => {
// Although these checks should most likely cover non 'main' types, for now it narrows
// the scope of our checks.
if (loaderType !== 'main' || !startingMedia || !trackInfo) {
return null;
}
if (!trackInfo.hasAudio && !trackInfo.hasVideo) {
return 'Neither audio nor video found in segment.';
}
if (startingMedia.hasVideo && !trackInfo.hasVideo) {
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.hasVideo && trackInfo.hasVideo) {
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 interrupting
* 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 interrupting playback
*/
export const safeBackBufferTrimTime = (seekable, currentTime, targetDuration) => {
// 30 seconds before the playhead provides a safe default for trimming.
//
// Choosing a reasonable default is particularly important for high bitrate content and
// VOD videos/live streams with large windows, as the buffer may end up overfilled and
// throw an APPEND_BUFFER_ERR.
let trimTime = currentTime - Config.BACK_BUFFER_LENGTH;
if (seekable.length) {
// Some live playlists may have a shorter window of content than the full allowed back
// buffer. For these playlists, don't save content that's no longer within the window.
trimTime = Math.max(trimTime, seekable.start(0));
}
// Don't remove within target duration of the current time to avoid the possibility of
// removing the GOP currently being played, as removing it can cause playback stalls.
const maxTrimTime = currentTime - targetDuration;
return Math.min(maxTrimTime, trimTime);
};
const segmentInfoString = (segmentInfo) => {
const {
segment: {
start,
end
},
playlist: {
mediaSequence: seq,
id,
segments = []
},
mediaIndex: index,
timeline
} = segmentInfo;
return [
`appending [${index}] of [${seq}, ${seq + segments.length}] from playlist [${id}]`,
`[${start} => ${end}] in timeline [${timeline}]`
].join(' ');
};
const timingInfoPropertyForMedia = (mediaType) => `${mediaType}TimingInfo`;
/**
* Returns the timestamp offset to use for the segment.
*
* @param {number} segmentTimeline
* The timeline of the segment
* @param {number} currentTimeline
* The timeline currently being followed by the loader
* @param {number} startOfSegment
* The estimated segment start
* @param {TimeRange[]} buffered
* The loader's buffer
* @param {boolean} overrideCheck
* If true, no checks are made to see if the timestamp offset value should be set,
* but sets it directly to a value.
*
* @return {number|null}
* Either a number representing a new timestamp offset, or null if the segment is
* part of the same timeline
*/
export const timestampOffsetForSegment = ({
segmentTimeline,
currentTimeline,
startOfSegment,
buffered,
overrideCheck
}) => {
// Check to see if we are crossing a discontinuity to see if we need to set the
// timestamp offset on the transmuxer and source buffer.
//
// Previously, we changed the timestampOffset if the start of this segment was less than
// the currently set timestampOffset, but this isn't desirable as it can produce bad
// behavior, especially around long running live streams.
if (!overrideCheck && segmentTimeline === currentTimeline) {
return null;
}
// When changing renditions, it's possible to request a segment on an older timeline. For
// instance, given two renditions with the following:
//
// #EXTINF:10
// segment1
// #EXT-X-DISCONTINUITY
// #EXTINF:10
// segment2
// #EXTINF:10
// segment3
//
// And the current player state:
//
// current time: 8
// buffer: 0 => 20
//
// The next segment on the current rendition would be segment3, filling the buffer from
// 20s onwards. However, if a rendition switch happens after segment2 was requested,
// then the next segment to be requested will be segment1 from the new rendition in
// order to fill time 8 and onwards. Using the buffered end would result in repeated
// content (since it would position segment1 of the new rendition starting at 20s). This
// case can be identified when the new segment's timeline is a prior value. Instead of
// using the buffered end, the startOfSegment can be used, which, hopefully, will be
// more accurate to the actual start time of the segment.
if (segmentTimeline < currentTimeline) {
return startOfSegment;
}
// segmentInfo.startOfSegment used to be used as the timestamp offset, however, that
// value uses the end of the last segment if it is available. While this value
// should often be correct, it's better to rely on the buffered end, as the new
// content post discontinuity should line up with the buffered end as if it were
// time 0 for the new content.
return buffered.length ? buffered.end(buffered.length - 1) : startOfSegment;
};
/**
* Returns whether or not the loader should wait for a timeline change from the timeline
* change controller before processing the segment.
*
* Primary timing in VHS goes by video. This is different from most media players, as
* audio is more often used as the primary timing source. For the foreseeable future, VHS
* will continue to use video as the primary timing source, due to the current logic and
* expectations built around it.
* Since the timing follows video, in order to maintain sync, the video loader is
* responsible for setting both audio and video source buffer timestamp offsets.
*
* Setting different values for audio and video source buffers could lead to
* desyncing. The following examples demonstrate some of the situations where this
* distinction is important. Note that all of these cases involve demuxed content. When
* content is muxed, the audio and video are packaged together, therefore syncing
* separate media playlists is not an issue.
*
* CASE 1: Audio prepares to load a new timeline before video:
*
* Timeline: 0 1
* Audio Segments: 0 1 2 3 4 5 DISCO 6 7 8 9
* Audio Loader: ^
* Video Segments: 0 1 2 3 4 5 DISCO 6 7 8 9
* Video Loader ^
*
* In the above example, the audio loader is preparing to load the 6th segment, the first
* after a discontinuity, while the video loader is still loading the 5th segment, before
* the discontinuity.
*
* If the audio loader goes ahead and loads and appends the 6th segment before the video
* loader crosses the discontinuity, then when appended, the 6th audio segment will use
* the timestamp offset from timeline 0. This will likely lead to desyncing. In addition,
* the audio loader must provide the audioAppendStart value to trim the content in the
* transmuxer, and that value relies on the audio timestamp offset. Since the audio
* timestamp offset is set by the video (main) loader, the audio loader shouldn't load the
* segment until that value is provided.
*
* CASE 2: Video prepares to load a new timeline before audio:
*
* Timeline: 0 1
* Audio Segments: 0 1 2 3 4 5 DISCO 6 7 8 9
* Audio Loader: ^
* Video Segments: 0 1 2 3 4 5 DISCO 6 7 8 9
* Video Loader ^
*
* In the above example, the video loader is preparing to load the 6th segment, the first
* after a discontinuity, while the audio loader is still loading the 5th segment, before
* the discontinuity.
*
* If the video loader goes ahead and loads and appends the 6th segment, then once the
* segment is loaded and processed, both the video and audio timestamp offsets will be
* set, since video is used as the primary timing source. This is to ensure content lines
* up appropriately, as any modifications to the video timing are reflected by audio when
* the video loader sets the audio and video timestamp offsets to the same value. However,
* setting the timestamp offset for audio before audio has had a chance to change
* timelines will likely lead to desyncing, as the audio loader will append segment 5 with
* a timestamp intended to apply to segments from timeline 1 rather than timeline 0.
*
* CASE 3: When seeking, audio prepares to load a new timeline before video
*
* Timeline: 0 1
* Audio Segments: 0 1 2 3 4 5 DISCO 6 7 8 9
* Audio Loader: ^
* Video Segments: 0 1 2 3 4 5 DISCO 6 7 8 9
* Video Loader ^
*
* In the above example, both audio and video loaders are loading segments from timeline
* 0, but imagine that the seek originated from timeline 1.
*
* When seeking to a new timeline, the timestamp offset will be set based on the expected
* segment start of the loaded video segment. In order to maintain sync, the audio loader
* must wait for the video loader to load its segment and update both the audio and video
* timestamp offsets before it may load and append its own segment. This is the case
* whether the seek results in a mismatched segment request (e.g., the audio loader
* chooses to load segment 3 and the video loader chooses to load segment 4) or the
* loaders choose to load the same segment index from each playlist, as the segments may
* not be aligned perfectly, even for matching segment indexes.
*
* @param {Object} timelinechangeController
* @param {number} currentTimeline
* The timeline currently being followed by the loader
* @param {number} segmentTimeline
* The timeline of the segment being loaded
* @param {('main'|'audio')} loaderType
* The loader type
* @param {boolean} audioDisabled
* Whether the audio is disabled for the loader. This should only be true when the
* loader may have muxed audio in its segment, but should not append it, e.g., for
* the main loader when an alternate audio playlist is active.
*
* @return {boolean}
* Whether the loader should wait for a timeline change from the timeline change
* controller before processing the segment
*/
export const shouldWaitForTimelineChange = ({
timelineChangeController,
currentTimeline,
segmentTimeline,
loaderType,
audioDisabled
}) => {
if (currentTimeline === segmentTimeline) {
return false;
}
if (loaderType === 'audio') {
const lastMainTimelineChange = timelineChangeController.lastTimelineChange({
type: 'main'
});
// Audio loader should wait if:
//
// * main hasn't had a timeline change yet (thus has not loaded its first segment)
// * main hasn't yet changed to the timeline audio is looking to load
return !lastMainTimelineChange || lastMainTimelineChange.to !== segmentTimeline;
}
// The main loader only needs to wait for timeline changes if there's demuxed audio.
// Otherwise, there's nothing to wait for, since audio would be muxed into the main
// loader's segments (or the content is audio/video only and handled by the main
// loader).
if (loaderType === 'main' && audioDisabled) {
const pendingAudioTimelineChange = timelineChangeController.pendingTimelineChange({
type: 'audio'
});
// Main loader should wait for the audio loader if audio is not pending a timeline
// change to the current timeline.
//
// Since the main loader is responsible for setting the timestamp offset for both
// audio and video, the main loader must wait for audio to be about to change to its
// timeline before setting the offset, otherwise, if audio is behind in loading,
// segments from the previous timeline would be adjusted by the new timestamp offset.
//
// This requirement means that video will not cross a timeline until the audio is
// about to cross to it, so that way audio and video will always cross the timeline
// together.
//
// In addition to normal timeline changes, these rules also apply to the start of a
// stream (going from a non-existent timeline, -1, to timeline 0). It's important
// that these rules apply to the first timeline change because if they did not, it's
// possible that the main loader will cross two timelines before the audio loader has
// crossed one. Logic may be implemented to handle the startup as a special case, but
// it's easier to simply treat all timeline changes the same.
if (pendingAudioTimelineChange && pendingAudioTimelineChange.to === segmentTimeline) {
return false;
}
return true;
}
return false;
};
export const mediaDuration = (audioTimingInfo, videoTimingInfo) => {
const audioDuration =
audioTimingInfo &&
typeof audioTimingInfo.start === 'number' &&
typeof audioTimingInfo.end === 'number' ?
audioTimingInfo.end - audioTimingInfo.start : 0;
const videoDuration =
videoTimingInfo &&
typeof videoTimingInfo.start === 'number' &&
typeof videoTimingInfo.end === 'number' ?
videoTimingInfo.end - videoTimingInfo.start : 0;
return Math.max(audioDuration, videoDuration);
};
export const segmentTooLong = ({ segmentDuration, maxDuration }) => {
// 0 duration segments are most likely due to metadata only segments or a lack of
// information.
if (!segmentDuration) {
return false;
}
// For HLS:
//
// https://tools.ietf.org/html/draft-pantos-http-live-streaming-23#section-4.3.3.1
// The EXTINF duration of each Media Segment in the Playlist
// file, when rounded to the nearest integer, MUST be less than or equal
// to the target duration; longer segments can trigger playback stalls
// or other errors.
//
// For DASH, the mpd-parser uses the largest reported segment duration as the target
// duration. Although that reported duration is occasionally approximate (i.e., not
// exact), a strict check may report that a segment is too long more often in DASH.
return Math.round(segmentDuration) > maxDuration + TIME_FUDGE_FACTOR;
};
export const getTroublesomeSegmentDurationMessage = (segmentInfo, sourceType) => {
// Right now we aren't following DASH's timing model exactly, so only perform
// this check for HLS content.
if (sourceType !== 'hls') {
return null;
}
const segmentDuration = mediaDuration(
segmentInfo.audioTimingInfo,
segmentInfo.videoTimingInfo
);
// Don't report if we lack information.
//
// If the segment has a duration of 0 it is either a lack of information or a
// metadata only segment and shouldn't be reported here.
if (!segmentDuration) {
return null;
}
const targetDuration = segmentInfo.playlist.targetDuration;
const isSegmentWayTooLong = segmentTooLong({
segmentDuration,
maxDuration: targetDuration * 2
});
const isSegmentSlightlyTooLong = segmentTooLong({
segmentDuration,
maxDuration: targetDuration
});
const segmentTooLongMessage = `Segment with index ${segmentInfo.mediaIndex} ` +
`from playlist ${segmentInfo.playlist.id} ` +
`has a duration of ${segmentDuration} ` +
`when the reported duration is ${segmentInfo.duration} ` +
`and the target duration is ${targetDuration}. ` +
'For HLS content, a duration in excess of the target duration may result in ' +
'playback issues. See the HLS specification section on EXT-X-TARGETDURATION for ' +
'more details: ' +
'https://tools.ietf.org/html/draft-pantos-http-live-streaming-23#section-4.3.3.1';
if (isSegmentWayTooLong || isSegmentSlightlyTooLong) {
return {
severity: isSegmentWayTooLong ? 'warn' : 'info',
message: segmentTooLongMessage
};
}
return null;
};
/**
* 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.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.vhs_ = settings.vhs;
this.loaderType_ = settings.loaderType;
this.currentMediaInfo_ = void 0;
this.startingMediaInfo_ = void 0;
this.segmentMetadataTrack_ = settings.segmentMetadataTrack;
this.goalBufferLength_ = settings.goalBufferLength;
this.sourceType_ = settings.sourceType;
this.sourceUpdater_ = settings.sourceUpdater;
this.inbandTextTracks_ = settings.inbandTextTracks;
this.state_ = 'INIT';
this.handlePartialData_ = settings.handlePartialData;
this.timelineChangeController_ = settings.timelineChangeController;
this.shouldSaveSegmentTimingInfo_ = true;
this.parse708captions_ = settings.parse708captions;
// private instance variables
this.checkBufferTimeout_ = null;
this.error_ = void 0;
this.currentTimeline_ = -1;
this.pendingSegment_ = null;
this.xhrOptions_ = null;
this.pendingSegments_ = [];
this.audioDisabled_ = false;
this.isPendingTimestampOffset_ = false;
// TODO possibly move gopBuffer and timeMapping info to a separate controller
this.gopBuffer_ = [];
this.timeMapping_ = 0;
this.safeAppend_ = videojs.browser.IE_VERSION >= 11;
this.appendInitSegment_ = {
audio: true,
video: true
};
this.playlistOfLastInitSegment_ = {
audio: null,
video: null
};
this.callQueue_ = [];
// If the segment loader prepares to load a segment, but does not have enough
// information yet to start the loading process (e.g., if the audio loader wants to
// load a segment from the next timeline but the main loader hasn't yet crossed that
// timeline), then the load call will be added to the queue until it is ready to be
// processed.
this.loadQueue_ = [];
this.metadataQueue_ = {
id3: [],
caption: []
};
// Fragmented mp4 playback
this.activeInitSegmentId_ = null;
this.initSegments_ = {};
// HLSe playback
this.cacheEncryptionKeys_ = settings.cacheEncryptionKeys;
this.keyCache_ = {};
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.transmuxer_ = this.createTransmuxer_();
this.triggerSyncInfoUpdate_ = () => this.trigger('syncinfoupdate');
this.syncController_.on('syncinfoupdate', this.triggerSyncInfoUpdate_);
this.mediaSource_.addEventListener('sourceopen', () => {
if (!this.isEndOfStream_()) {
this.ended_ = false;
}
});
// ...for determining the fetch location
this.fetchAtBuffer_ = false;
this.logger_ = logger(`SegmentLoader[${this.loaderType_}]`);
Object.defineProperty(this, 'state', {
get() {
return this.state_;
},
set(newState) {
if (newState !== this.state_) {
this.logger_(`${this.state_} -> ${newState}`);
this.state_ = newState;
this.trigger('statechange');
}
}
});
this.sourceUpdater_.on('ready', () => {
if (this.hasEnoughInfoToAppend_()) {
this.processCallQueue_();
}
});
// Only the main loader needs to listen for pending timeline changes, as the main
// loader should wait for audio to be ready to change its timeline so that both main
// and audio timelines change together. For more details, see the
// shouldWaitForTimelineChange function.
if (this.loaderType_ === 'main') {
this.timelineChangeController_.on('pendingtimelinechange', () => {
if (this.hasEnoughInfoToAppend_()) {
this.processCallQueue_();
}
});
}
// The main loader only listens on pending timeline changes, but the audio loader,
// since its loads follow main, needs to listen on timeline changes. For more details,
// see the shouldWaitForTimelineChange function.
if (this.loaderType_ === 'audio') {
this.timelineChangeController_.on('timelinechange', () => {
if (this.hasEnoughInfoToLoad_()) {
this.processLoadQueue_();
}
if (this.hasEnoughInfoToAppend_()) {
this.processCallQueue_();
}
});
}
}
createTransmuxer_() {
return segmentTransmuxer.createTransmuxer({
remux: false,
alignGopsAtEnd: this.safeAppend_,
keepOriginalTimestamps: true,
handlePartialData: this.handlePartialData_,
parse708captions: this.parse708captions_
});
}
/**
* 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.trigger('dispose');
this.state = 'DISPOSED';
this.pause();
this.abort_();
if (this.transmuxer_) {
this.transmuxer_.terminate();
}
this.resetStats_();
if (this.checkBufferTimeout_) {
window.clearTimeout(this.checkBufferTimeout_);
}
if (this.syncController_ && this.triggerSyncInfoUpdate_) {
this.syncController_.off('syncinfoupdate', this.triggerSyncInfoUpdate_);
}
this.off();
}
setAudio(enable) {
this.audioDisabled_ = !enable;
if (enable) {
this.appendInitSegment_.audio = true;
} else {
// remove current track audio if it gets disabled
this.sourceUpdater_.removeAudio(0, this.duration_());
}
}
/**
* 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) {
this.pendingSegment_.abortRequests();
}
// clear out the segment being processed
this.pendingSegment_ = null;
this.callQueue_ = [];
this.loadQueue_ = [];
this.metadataQueue_.id3 = [];
this.metadataQueue_.caption = [];
this.timelineChangeController_.clearPendingTimelineChange(this.loaderType_);
}
checkForAbort_(requestId) {
// If the state is APPENDING, then aborts will not modify the state, meaning the first
// callback that happens should reset the state to READY so that loading can continue.
if (this.state === 'APPENDING' && !this.pendingSegment_) {
this.state = 'READY';
return true;
}
if (!this.pendingSegment_ || this.pendingSegment_.requestId !== requestId) {
return true;
}
return false;
}
/**
* 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.logger_('error occurred:', error);
this.error_ = error;
}
this.pendingSegment_ = null;
return this.error_;
}
endOfStream() {
this.ended_ = true;
if (this.transmuxer_) {
// need to clear out any cached data to prepare for the new segment
segmentTransmuxer.reset(this.transmuxer_);
}
this.gopBuffer_.length = 0;
this.pause();
this.trigger('ended');
}
/**
* Indicates which time ranges are buffered
*
* @return {TimeRange}
* TimeRange object representing the current buffered ranges
*/
buffered_() {
if (!this.sourceUpdater_ || !this.startingMediaInfo_) {
return videojs.createTimeRanges();
}
if (this.loaderType_ === 'main') {
const { hasAudio, hasVideo, isMuxed } = this.startingMediaInfo_;
if (hasVideo && hasAudio && !this.audioDisabled_ && !isMuxed) {
return this.sourceUpdater_.buffered();
}
if (hasVideo) {
return this.sourceUpdater_.videoBuffered();
}
}
// One case that can be ignored for now is audio only with alt audio,
// as we don't yet have proper support for that.
return this.sourceUpdater_.audioBuffered();
}
/**
* 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
*/
initSegmentForMap(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,
tracks: map.tracks,
timescales: map.timescales
};
}
return storedMap || map;
}
/**
* Gets and sets key for the provided key
*
* @param {Object} key
* The key object representing the key to get or set
* @param {boolean=} set
* If true, the key for the provided key should be saved
* @return {Object}
* Key object for desired key
*/
segmentKey(key, set = false) {
if (!key) {
return null;
}
const id = segmentKeyId(key);
let storedKey = this.keyCache_[id];
// TODO: We should use the HTTP Expires header to invalidate our cache per
// https://tools.ietf.org/html/draft-pantos-http-live-streaming-23#section-6.2.3
if (this.cacheEncryptionKeys_ && set && !storedKey && key.bytes) {
this.keyCache_[id] = storedKey = {
resolvedUri: key.resolvedUri,
bytes: key.bytes
};
}
const result = {
resolvedUri: (storedKey || key).resolvedUri
};
if (storedKey) {
result.bytes = storedKey.bytes;
}
return result;
}
/**
* 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_ && !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;
}
// 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';
// if this is the audio segment loader, and it hasn't been inited before, then any old
// audio data from the muxed content should be removed
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;
}
const oldPlaylist = this.playlist_;
const 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
//
// Use the INIT state to determine if playback has started, as the playlist sync info
// should be fixed once requests begin (as sync points are generated based on sync
// info), but not before then.
if (this.state === 'INIT') {
newPlaylist.syncInfo = {
mediaSequence: newPlaylist.mediaSequence,
time: 0
};
// Setting the date time mapping means mapping the program date time (if available)
// to time 0 on the player's timeline. The playlist's syncInfo serves a similar
// purpose, mapping the initial mediaSequence to time zero. Since the syncInfo can
// be updated as the playlist is refreshed before the loader starts loading, the
// program date time mapping needs to be updated as well.
//
// This mapping is only done for the main loader because a program date time should
// map equivalently between playlists.
if (this.loaderType_ === 'main') {
this.syncController_.setDateTimeMappingForStart(newPlaylist);
}
}
let oldId = null;
if (oldPlaylist) {
if (oldPlaylist.id) {
oldId = oldPlaylist.id;
} else if (oldPlaylist.uri) {
oldId = oldPlaylist.uri;
}
}
this.logger_(`playlist update [${oldId} => ${newPlaylist.id || newPlaylist.uri}]`);
// 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 || this.handlePartialData_) {
// we must "resync" the segment loader when we switch renditions and
// the segment loader is already synced to the previous rendition
//
// or if we're handling partial data, we need to ensure the transmuxer is cleared
// out before we start adding more data
this.resyncLoader();
}
this.currentMediaInfo_ = void 0;
this.trigger('playlistupdate');
// 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
const mediaSequenceDiff = newPlaylist.mediaSequence - oldPlaylist.mediaSequence;
this.logger_(`live window shift [${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 `handleAppendsDone_` 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;
}
/**
* Delete all the buffered data and reset the SegmentLoader
*
* @param {Function} [done] an optional callback to be executed when the remove
* operation is complete
*/
resetEverything(done) {
this.ended_ = false;
this.appendInitSegment_ = {
audio: true,
video: true
};
this.resetLoader();
// remove from 0, the earliest point, to Infinity, to signify removal of everything.
// VTT Segment Loader doesn't need to do anything but in the regular SegmentLoader,
// we then clamp the value to duration if necessary.
this.remove(0, Infinity, done);
// clears fmp4 captions
if (this.transmuxer_) {
this.transmuxer_.postMessage({
action: 'clearAllMp4Captions'
});
}
}
/**
* 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() {
if (this.transmuxer_) {
// need to clear out any cached data to prepare for the new segment
segmentTransmuxer.reset(this.transmuxer_);
}
this.mediaIndex = null;
this.syncPoint_ = null;
this.isPendingTimestampOffset_ = false;
this.callQueue_ = [];
this.loadQueue_ = [];
this.metadataQueue_.id3 = [];
this.metadataQueue_.caption = [];
this.abort();
if (this.transmuxer_) {
this.transmuxer_.postMessage({
action: 'clearParsedMp4Captions'
});
}
}
/**
* 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
* @param {Function} [done] - an optional callback to be executed when the remove
* operation is complete
*/
remove(start, end, done = () => {}) {
// clamp end to duration if we need to remove everything.
// This is due to a browser bug that causes issues if we remove to Infinity.
// videojs/videojs-contrib-hls#1225
if (end === Infinity) {
end = this.duration_();
}
if (!this.sourceUpdater_ || !this.currentMediaInfo_) {
// nothing to remove if we haven't processed any media
return;
}
// set it to one to complete this function's removes
let removesRemaining = 1;
const removeFinished = () => {
removesRemaining--;
if (removesRemaining === 0) {
done();
}
};
if (!this.audioDisabled_) {
removesRemaining++;
this.sourceUpdater_.removeAudio(start, end, removeFinished);
}
if (this.loaderType_ === 'main' && this.currentMediaInfo_ && this.currentMediaInfo_.hasVideo) {
this.gopBuffer_ = removeGopBuffer(this.gopBuffer_, start, end, this.timeMapping_);
removesRemaining++;
this.sourceUpdater_.removeVideo(start, end, removeFinished);
}
// remove any captions and ID3 tags
for (const track in this.inbandTextTracks_) {
removeCuesFromTrack(start, end, this.inbandTextTracks_[track]);
}
removeCuesFromTrack(start, end, this.segmentMetadataTrack_);
// finished this function's removes
removeFinished();
}
/**
* (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_() {
// TODO since the source buffer maintains a queue, and we shouldn't call this function
// except when we're ready for the next segment, this check can most likely be removed
if (this.sourceUpdater_.updating()) {
return;
}
if (!this.syncPoint_) {
this.syncPoint_ = this.syncController_.getSyncPoint(
this.playlist_,
this.duration_(),
this.currentTimeline_,
this.currentTime_()
);
}
const buffered = this.buffered_();
// see if we need to begin loading immediately
const segmentInfo = this.checkBuffer_(
buffered,
this.playlist_,
this.mediaIndex,
this.hasPlayed_(),
this.currentTime_(),
this.syncPoint_
);
if (!segmentInfo) {
return;
}
segmentInfo.timestampOffset = timestampOffsetForSegment({
segmentTimeline: segmentInfo.timeline,
currentTimeline: this.currentTimeline_,
startOfSegment: segmentInfo.startOfSegment,
buffered,
overrideCheck: this.isPendingTimestampOffset_
});
this.isPendingTimestampOffset_ = false;
if (typeof segmentInfo.timestampOffset === 'number') {
this.timelineChangeController_.pendingTimelineChange({
type: this.loaderType_,
from: this.currentTimeline_,
to: segmentInfo.timeline
});
}
this.loadSegment_(segmentInfo);
}
/**
* 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 {number} [mediaIndex] the media index of segment we last appended
* @param {Object} [playlist] a media playlist object
* @return {boolean} do we need to call endOfStream on the MediaSource
*/
isEndOfStream_(mediaIndex = this.mediaIndex, playlist = this.playlist_) {
if (!playlist || !this.mediaSource_) {
return false;
}
// mediaIndex is zero based but length is 1 based
const appendedLastSegment = (mediaIndex + 1) === playlist.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 && this.mediaSource_.readyState === 'open' && appendedLastSegment;
}
/**
* 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
* @return {Object} a segment request object that describes the segment to load
*/
checkBuffer_(buffered, playlist, currentMediaIndex, hasPlayed, currentTime, syncPoint) {
let lastBufferedEnd = 0;
if (buffered.length) {
lastBufferedEnd = buffered.end(buffered.length - 1);
}
const 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;
}
let nextMediaIndex = null;
let startOfSegment;
let isSyncRequest = false;
// 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) {
nextMediaIndex = this.getSyncSegmentCandidate_(playlist);
isSyncRequest = true;
} else if (currentMediaIndex !== null) {
// Under normal playback conditions fetching is a simple walk forward
const segment = playlist.segments[currentMediaIndex];
if (segment && segment.end) {
startOfSegment = segment.end;
} else {
startOfSegment = lastBufferedEnd;
}
nextMediaIndex = currentMediaIndex + 1;
// 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
} else if (this.fetchAtBuffer_) {
// Find the segment containing the end of the buffer
const mediaSourceInfo = Playlist.getMediaInfoForTime(
playlist,
lastBufferedEnd,
syncPoint.segmentIndex,
syncPoint.time
);
nextMediaIndex = mediaSourceInfo.mediaIndex;
startOfSegment = mediaSourceInfo.startTime;
} else {
// Find the segment containing currentTime
const mediaSourceInfo = Playlist.getMediaInfoForTime(
playlist,
currentTime,
syncPoint.segmentIndex,
syncPoint.time
);
nextMediaIndex = mediaSourceInfo.mediaIndex;
startOfSegment = mediaSourceInfo.startTime;
}
const segmentInfo = this.generateSegmentInfo_(playlist, nextMediaIndex, startOfSegment, isSyncRequest);
if (!segmentInfo) {
return;
}
// if this is the last segment in the playlist
// we are not seeking and end of stream has already been called
// do not re-request
if (this.mediaSource_ && this.playlist_ && segmentInfo.mediaIndex === this.playlist_.segments.length - 1 &&
this.mediaSource_.readyState === 'ended' &&
!this.seeking_()) {
return;
}
this.logger_(`checkBuffer_ returning ${segmentInfo.uri}`, {
segmentInfo,
playlist,
currentMediaIndex,
nextMediaIndex,
startOfSegment,
isSyncRequest
});
return segmentInfo;
}
/**
* 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
* @return {number} An index of a segment from the playlist to load
*/
getSyncSegmentCandidate_(playlist) {
if (this.currentTimeline_ === -1) {
return 0;
}
const 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;
}
const segment = playlist.segments[mediaIndex];
const audioBuffered = this.sourceUpdater_.audioBuffered();
const videoBuffered = this.sourceUpdater_.videoBuffered();
let audioAppendStart;
let gopsToAlignWith;
if (audioBuffered.length) {
// since the transmuxer is using the actual timing values, but the buffer is
// adjusted by the timestamp offset, we must adjust the value here
audioAppendStart = audioBuffered.end(audioBuffered.length - 1) -
this.sourceUpdater_.audioTimestampOffset();
}
if (videoBuffered.length) {
gopsToAlignWith = gopsSafeToAlignWith(
this.gopBuffer_,
// since the transmuxer is using the actual timing values, but the time is
// adjusted by the timestmap offset, we must adjust the value here
this.currentTime_() - this.sourceUpdater_.videoTimestampOffset(),
this.timeMapping_
);
}
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,
byteLength: 0,
transmuxer: this.transmuxer_,
audioAppendStart,
gopsToAlignWith
};
}
/**
* 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
* @private
*/
earlyAbortWhenNeeded_(stats) {
if (this.vhs_.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 n