UNPKG

@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
/** * @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