UNPKG

shaka-player

Version:
1,397 lines (1,212 loc) 95.7 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview * @suppress {missingRequire} TODO(b/152540451): this shouldn't be needed */ goog.provide('shaka.media.StreamingEngine'); goog.require('goog.asserts'); goog.require('shaka.log'); goog.require('shaka.media.InitSegmentReference'); goog.require('shaka.media.MediaSourceEngine'); goog.require('shaka.media.SegmentIterator'); goog.require('shaka.media.SegmentReference'); goog.require('shaka.media.SegmentPrefetch'); goog.require('shaka.net.Backoff'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.util.DelayedTick'); goog.require('shaka.util.Destroyer'); goog.require('shaka.util.Error'); goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.IDestroyable'); goog.require('shaka.util.Id3Utils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Mp4Parser'); goog.require('shaka.util.Networking'); /** * @summary Creates a Streaming Engine. * The StreamingEngine is responsible for setting up the Manifest's Streams * (i.e., for calling each Stream's createSegmentIndex() function), for * downloading segments, for co-ordinating audio, video, and text buffering. * The StreamingEngine provides an interface to switch between Streams, but it * does not choose which Streams to switch to. * * The StreamingEngine does not need to be notified about changes to the * Manifest's SegmentIndexes; however, it does need to be notified when new * Variants are added to the Manifest. * * To start the StreamingEngine the owner must first call configure(), followed * by one call to switchVariant(), one optional call to switchTextStream(), and * finally a call to start(). After start() resolves, switch*() can be used * freely. * * The owner must call seeked() each time the playhead moves to a new location * within the presentation timeline; however, the owner may forego calling * seeked() when the playhead moves outside the presentation timeline. * * @implements {shaka.util.IDestroyable} */ shaka.media.StreamingEngine = class { /** * @param {shaka.extern.Manifest} manifest * @param {shaka.media.StreamingEngine.PlayerInterface} playerInterface */ constructor(manifest, playerInterface) { /** @private {?shaka.media.StreamingEngine.PlayerInterface} */ this.playerInterface_ = playerInterface; /** @private {?shaka.extern.Manifest} */ this.manifest_ = manifest; /** @private {?shaka.extern.StreamingConfiguration} */ this.config_ = null; /** @private {number} */ this.bufferingGoalScale_ = 1; /** @private {?shaka.extern.Variant} */ this.currentVariant_ = null; /** @private {?shaka.extern.Stream} */ this.currentTextStream_ = null; /** @private {number} */ this.textStreamSequenceId_ = 0; /** @private {boolean} */ this.parsedPrftEventRaised_ = false; /** * Maps a content type, e.g., 'audio', 'video', or 'text', to a MediaState. * * @private {!Map.<shaka.util.ManifestParserUtils.ContentType, * !shaka.media.StreamingEngine.MediaState_>} */ this.mediaStates_ = new Map(); /** * Set to true once the initial media states have been created. * * @private {boolean} */ this.startupComplete_ = false; /** * Used for delay and backoff of failure callbacks, so that apps do not * retry instantly. * * @private {shaka.net.Backoff} */ this.failureCallbackBackoff_ = null; /** * Set to true on fatal error. Interrupts fetchAndAppend_(). * * @private {boolean} */ this.fatalError_ = false; /** @private {!shaka.util.Destroyer} */ this.destroyer_ = new shaka.util.Destroyer(() => this.doDestroy_()); } /** @override */ destroy() { return this.destroyer_.destroy(); } /** * @return {!Promise} * @private */ async doDestroy_() { const aborts = []; for (const state of this.mediaStates_.values()) { this.cancelUpdate_(state); aborts.push(this.abortOperations_(state)); } await Promise.all(aborts); this.mediaStates_.clear(); this.playerInterface_ = null; this.manifest_ = null; this.config_ = null; } /** * Called by the Player to provide an updated configuration any time it * changes. Must be called at least once before start(). * * @param {shaka.extern.StreamingConfiguration} config */ configure(config) { this.config_ = config; // Create separate parameters for backoff during streaming failure. /** @type {shaka.extern.RetryParameters} */ const failureRetryParams = { // The term "attempts" includes the initial attempt, plus all retries. // In order to see a delay, there would have to be at least 2 attempts. maxAttempts: Math.max(config.retryParameters.maxAttempts, 2), baseDelay: config.retryParameters.baseDelay, backoffFactor: config.retryParameters.backoffFactor, fuzzFactor: config.retryParameters.fuzzFactor, timeout: 0, // irrelevant stallTimeout: 0, // irrelevant connectionTimeout: 0, // irrelevant }; // We don't want to ever run out of attempts. The application should be // allowed to retry streaming infinitely if it wishes. const autoReset = true; this.failureCallbackBackoff_ = new shaka.net.Backoff(failureRetryParams, autoReset); // Allow configuring the segment prefetch in middle of the playback. for (const type of this.mediaStates_.keys()) { const state = this.mediaStates_.get(type); if (state.segmentPrefetch) { state.segmentPrefetch.resetLimit(config.segmentPrefetchLimit); if (!(config.segmentPrefetchLimit > 0)) { // ResetLimit is still needed in this case, // to abort existing prefetch operations. state.segmentPrefetch = null; } } else if (config.segmentPrefetchLimit > 0) { state.segmentPrefetch = this.createSegmentPrefetch_(state.stream); } } } /** * Initialize and start streaming. * * By calling this method, StreamingEngine will start streaming the variant * chosen by a prior call to switchVariant(), and optionally, the text stream * chosen by a prior call to switchTextStream(). Once the Promise resolves, * switch*() may be called freely. * * @return {!Promise} */ async start() { goog.asserts.assert(this.config_, 'StreamingEngine configure() must be called before init()!'); // Setup the initial set of Streams and then begin each update cycle. await this.initStreams_(); this.destroyer_.ensureNotDestroyed(); shaka.log.debug('init: completed initial Stream setup'); this.startupComplete_ = true; } /** * Get the current variant we are streaming. Returns null if nothing is * streaming. * @return {?shaka.extern.Variant} */ getCurrentVariant() { return this.currentVariant_; } /** * Get the text stream we are streaming. Returns null if there is no text * streaming. * @return {?shaka.extern.Stream} */ getCurrentTextStream() { return this.currentTextStream_; } /** * Start streaming text, creating a new media state. * * @param {shaka.extern.Stream} stream * @return {!Promise} * @private */ async loadNewTextStream_(stream) { const ContentType = shaka.util.ManifestParserUtils.ContentType; goog.asserts.assert(!this.mediaStates_.has(ContentType.TEXT), 'Should not call loadNewTextStream_ while streaming text!'); this.textStreamSequenceId_++; const currentSequenceId = this.textStreamSequenceId_; try { // Clear MediaSource's buffered text, so that the new text stream will // properly replace the old buffered text. // TODO: Should this happen in unloadTextStream() instead? await this.playerInterface_.mediaSourceEngine.clear(ContentType.TEXT); } catch (error) { if (this.playerInterface_) { this.playerInterface_.onError(error); } } const mimeType = shaka.util.MimeUtils.getFullType( stream.mimeType, stream.codecs); this.playerInterface_.mediaSourceEngine.reinitText( mimeType, this.manifest_.sequenceMode, stream.external); const textDisplayer = this.playerInterface_.mediaSourceEngine.getTextDisplayer(); const streamText = textDisplayer.isTextVisible() || this.config_.alwaysStreamText; if (streamText && (this.textStreamSequenceId_ == currentSequenceId)) { const state = this.createMediaState_(stream); this.mediaStates_.set(ContentType.TEXT, state); this.scheduleUpdate_(state, 0); } } /** * Stop fetching text stream when the user chooses to hide the captions. */ unloadTextStream() { const ContentType = shaka.util.ManifestParserUtils.ContentType; const state = this.mediaStates_.get(ContentType.TEXT); if (state) { this.cancelUpdate_(state); this.abortOperations_(state).catch(() => {}); this.mediaStates_.delete(ContentType.TEXT); } this.currentTextStream_ = null; } /** * Set trick play on or off. * If trick play is on, related trick play streams will be used when possible. * @param {boolean} on */ setTrickPlay(on) { const ContentType = shaka.util.ManifestParserUtils.ContentType; const mediaState = this.mediaStates_.get(ContentType.VIDEO); if (!mediaState) { return; } const stream = mediaState.stream; if (!stream) { return; } shaka.log.debug('setTrickPlay', on); if (on) { const trickModeVideo = stream.trickModeVideo; if (!trickModeVideo) { return; // Can't engage trick play. } const normalVideo = mediaState.restoreStreamAfterTrickPlay; if (normalVideo) { return; // Already in trick play. } shaka.log.debug('Engaging trick mode stream', trickModeVideo); this.switchInternal_(trickModeVideo, /* clearBuffer= */ false, /* safeMargin= */ 0, /* force= */ false); mediaState.restoreStreamAfterTrickPlay = stream; } else { const normalVideo = mediaState.restoreStreamAfterTrickPlay; if (!normalVideo) { return; } shaka.log.debug('Restoring non-trick-mode stream', normalVideo); mediaState.restoreStreamAfterTrickPlay = null; this.switchInternal_(normalVideo, /* clearBuffer= */ true, /* safeMargin= */ 0, /* force= */ false); } } /** * @param {shaka.extern.Variant} variant * @param {boolean=} clearBuffer * @param {number=} safeMargin * @param {boolean=} force * If true, reload the variant even if it did not change. * @param {boolean=} adaptation * If true, update the media state to indicate MediaSourceEngine should * reset the timestamp offset to ensure the new track segments are correctly * placed on the timeline. */ switchVariant( variant, clearBuffer = false, safeMargin = 0, force = false, adaptation = false) { this.currentVariant_ = variant; if (!this.startupComplete_) { // The selected variant will be used in start(). return; } if (variant.video) { this.switchInternal_( variant.video, /* clearBuffer= */ clearBuffer, /* safeMargin= */ safeMargin, /* force= */ force, /* adaptation= */ adaptation); } if (variant.audio) { this.switchInternal_( variant.audio, /* clearBuffer= */ clearBuffer, /* safeMargin= */ safeMargin, /* force= */ force, /* adaptation= */ adaptation); } } /** * @param {shaka.extern.Stream} textStream */ async switchTextStream(textStream) { this.currentTextStream_ = textStream; if (!this.startupComplete_) { // The selected text stream will be used in start(). return; } const ContentType = shaka.util.ManifestParserUtils.ContentType; goog.asserts.assert(textStream && textStream.type == ContentType.TEXT, 'Wrong stream type passed to switchTextStream!'); // In HLS it is possible that the mimetype changes when the media // playlist is downloaded, so it is necessary to have the updated data // here. if (!textStream.segmentIndex) { await textStream.createSegmentIndex(); } this.switchInternal_( textStream, /* clearBuffer= */ true, /* safeMargin= */ 0, /* force= */ false); } /** Reload the current text stream. */ reloadTextStream() { const ContentType = shaka.util.ManifestParserUtils.ContentType; const mediaState = this.mediaStates_.get(ContentType.TEXT); if (mediaState) { // Don't reload if there's no text to begin with. this.switchInternal_( mediaState.stream, /* clearBuffer= */ true, /* safeMargin= */ 0, /* force= */ true); } } /** * Switches to the given Stream. |stream| may be from any Variant. * * @param {shaka.extern.Stream} stream * @param {boolean} clearBuffer * @param {number} safeMargin * @param {boolean} force * If true, reload the text stream even if it did not change. * @param {boolean=} adaptation * If true, update the media state to indicate MediaSourceEngine should * reset the timestamp offset to ensure the new track segments are correctly * placed on the timeline. * @private */ switchInternal_(stream, clearBuffer, safeMargin, force, adaptation) { const ContentType = shaka.util.ManifestParserUtils.ContentType; const type = /** @type {!ContentType} */(stream.type); const mediaState = this.mediaStates_.get(type); if (!mediaState && stream.type == ContentType.TEXT) { this.loadNewTextStream_(stream); return; } goog.asserts.assert(mediaState, 'switch: expected mediaState to exist'); if (!mediaState) { return; } if (mediaState.restoreStreamAfterTrickPlay) { shaka.log.debug('switch during trick play mode', stream); // Already in trick play mode, so stick with trick mode tracks if // possible. if (stream.trickModeVideo) { // Use the trick mode stream, but revert to the new selection later. mediaState.restoreStreamAfterTrickPlay = stream; stream = stream.trickModeVideo; shaka.log.debug('switch found trick play stream', stream); } else { // There is no special trick mode video for this stream! mediaState.restoreStreamAfterTrickPlay = null; shaka.log.debug('switch found no special trick play stream'); } } if (mediaState.stream == stream && !force) { const streamTag = shaka.media.StreamingEngine.logPrefix_(mediaState); shaka.log.debug('switch: Stream ' + streamTag + ' already active'); return; } if (mediaState.segmentPrefetch) { mediaState.segmentPrefetch.switchStream(stream); } if (stream.type == ContentType.TEXT) { // Mime types are allowed to change for text streams. // Reinitialize the text parser, but only if we are going to fetch the // init segment again. const fullMimeType = shaka.util.MimeUtils.getFullType( stream.mimeType, stream.codecs); this.playerInterface_.mediaSourceEngine.reinitText( fullMimeType, this.manifest_.sequenceMode, stream.external); } // Releases the segmentIndex of the old stream. if (mediaState.stream.closeSegmentIndex) { mediaState.stream.closeSegmentIndex(); } mediaState.stream = stream; mediaState.segmentIterator = null; mediaState.adaptation = !!adaptation; const streamTag = shaka.media.StreamingEngine.logPrefix_(mediaState); shaka.log.debug('switch: switching to Stream ' + streamTag); if (clearBuffer) { if (mediaState.clearingBuffer) { // We are already going to clear the buffer, but make sure it is also // flushed. mediaState.waitingToFlushBuffer = true; } else if (mediaState.performingUpdate) { // We are performing an update, so we have to wait until it's finished. // onUpdate_() will call clearBuffer_() when the update has finished. // We need to save the safe margin because its value will be needed when // clearing the buffer after the update. mediaState.waitingToClearBuffer = true; mediaState.clearBufferSafeMargin = safeMargin; mediaState.waitingToFlushBuffer = true; } else { // Cancel the update timer, if any. this.cancelUpdate_(mediaState); // Clear right away. this.clearBuffer_(mediaState, /* flush= */ true, safeMargin) .catch((error) => { if (this.playerInterface_) { goog.asserts.assert(error instanceof shaka.util.Error, 'Wrong error type!'); this.playerInterface_.onError(error); } }); } } this.makeAbortDecision_(mediaState).catch((error) => { if (this.playerInterface_) { goog.asserts.assert(error instanceof shaka.util.Error, 'Wrong error type!'); this.playerInterface_.onError(error); } }); } /** * Decide if it makes sense to abort the current operation, and abort it if * so. * * @param {!shaka.media.StreamingEngine.MediaState_} mediaState * @private */ async makeAbortDecision_(mediaState) { // If the operation is completed, it will be set to null, and there's no // need to abort the request. if (!mediaState.operation) { return; } const originalStream = mediaState.stream; const originalOperation = mediaState.operation; if (!originalStream.segmentIndex) { // Create the new segment index so the time taken is accounted for when // deciding whether to abort. await originalStream.createSegmentIndex(); } if (mediaState.operation != originalOperation) { // The original operation completed while we were getting a segment index, // so there's nothing to do now. return; } if (mediaState.stream != originalStream) { // The stream changed again while we were getting a segment index. We // can't carry out this check, since another one might be in progress by // now. return; } goog.asserts.assert(mediaState.stream.segmentIndex, 'Segment index should exist by now!'); if (this.shouldAbortCurrentRequest_(mediaState)) { shaka.log.info('Aborting current segment request.'); mediaState.operation.abort(); } } /** * Returns whether we should abort the current request. * * @param {!shaka.media.StreamingEngine.MediaState_} mediaState * @return {boolean} * @private */ shouldAbortCurrentRequest_(mediaState) { goog.asserts.assert(mediaState.operation, 'Abort logic requires an ongoing operation!'); goog.asserts.assert(mediaState.stream && mediaState.stream.segmentIndex, 'Abort logic requires a segment index'); const presentationTime = this.playerInterface_.getPresentationTime(); const bufferEnd = this.playerInterface_.mediaSourceEngine.bufferEnd(mediaState.type); // The next segment to append from the current stream. This doesn't // account for a pending network request and will likely be different from // that since we just switched. const timeNeeded = this.getTimeNeeded_(mediaState, presentationTime); const index = mediaState.stream.segmentIndex.find(timeNeeded); const newSegment = index == null ? null : mediaState.stream.segmentIndex.get(index); let newSegmentSize = newSegment ? newSegment.getSize() : null; if (newSegment && !newSegmentSize) { // compute approximate segment size using stream bandwidth const duration = newSegment.getEndTime() - newSegment.getStartTime(); const bandwidth = mediaState.stream.bandwidth || 0; // bandwidth is in bits per second, and the size is in bytes newSegmentSize = duration * bandwidth / 8; } if (!newSegmentSize) { return false; } // When switching, we'll need to download the init segment. const init = newSegment.initSegmentReference; if (init) { newSegmentSize += init.getSize() || 0; } const bandwidthEstimate = this.playerInterface_.getBandwidthEstimate(); // The estimate is in bits per second, and the size is in bytes. The time // remaining is in seconds after this calculation. const timeToFetchNewSegment = (newSegmentSize * 8) / bandwidthEstimate; // If the new segment can be finished in time without risking a buffer // underflow, we should abort the old one and switch. const bufferedAhead = (bufferEnd || 0) - presentationTime; const safetyBuffer = Math.max( this.manifest_.minBufferTime || 0, this.config_.rebufferingGoal); const safeBufferedAhead = bufferedAhead - safetyBuffer; if (timeToFetchNewSegment < safeBufferedAhead) { return true; } // If the thing we want to switch to will be done more quickly than what // we've got in progress, we should abort the old one and switch. const bytesRemaining = mediaState.operation.getBytesRemaining(); if (bytesRemaining > newSegmentSize) { return true; } // Otherwise, complete the operation in progress. return false; } /** * Notifies the StreamingEngine that the playhead has moved to a valid time * within the presentation timeline. */ seeked() { if (!this.playerInterface_) { // Already destroyed. return; } const presentationTime = this.playerInterface_.getPresentationTime(); const ContentType = shaka.util.ManifestParserUtils.ContentType; const newTimeIsBuffered = (type) => { return this.playerInterface_.mediaSourceEngine.isBuffered( type, presentationTime); }; let streamCleared = false; for (const type of this.mediaStates_.keys()) { const mediaState = this.mediaStates_.get(type); const logPrefix = shaka.media.StreamingEngine.logPrefix_(mediaState); let segment = null; if (mediaState.segmentIterator) { segment = mediaState.segmentIterator.current(); } // Only reset the iterator if we seek outside the current segment. if (!segment || segment.startTime > presentationTime || segment.endTime < presentationTime) { mediaState.segmentIterator = null; } if (!newTimeIsBuffered(type)) { const bufferEnd = this.playerInterface_.mediaSourceEngine.bufferEnd(type); const somethingBuffered = bufferEnd != null; // Don't clear the buffer unless something is buffered. This extra // check prevents extra, useless calls to clear the buffer. if (somethingBuffered || mediaState.performingUpdate) { this.forceClearBuffer_(mediaState); streamCleared = true; } // If there is an operation in progress, stop it now. if (mediaState.operation) { mediaState.operation.abort(); shaka.log.debug(logPrefix, 'Aborting operation due to seek'); mediaState.operation = null; } // The pts has shifted from the seek, invalidating captions currently // in the text buffer. Thus, clear and reset the caption parser. if (type === ContentType.TEXT) { this.playerInterface_.mediaSourceEngine.resetCaptionParser(); } // Mark the media state as having seeked, so that the new buffers know // that they will need to be at a new position (for sequence mode). mediaState.seeked = true; } } if (!streamCleared) { shaka.log.debug( '(all): seeked: buffered seek: presentationTime=' + presentationTime); } } /** * Clear the buffer for a given stream. Unlike clearBuffer_, this will handle * cases where a MediaState is performing an update. After this runs, the * MediaState will have a pending update. * @param {!shaka.media.StreamingEngine.MediaState_} mediaState * @private */ forceClearBuffer_(mediaState) { const logPrefix = shaka.media.StreamingEngine.logPrefix_(mediaState); if (mediaState.clearingBuffer) { // We're already clearing the buffer, so we don't need to clear the // buffer again. shaka.log.debug(logPrefix, 'clear: already clearing the buffer'); return; } if (mediaState.waitingToClearBuffer) { // May not be performing an update, but an update will still happen. // See: https://github.com/shaka-project/shaka-player/issues/334 shaka.log.debug(logPrefix, 'clear: already waiting'); return; } if (mediaState.performingUpdate) { // We are performing an update, so we have to wait until it's finished. // onUpdate_() will call clearBuffer_() when the update has finished. shaka.log.debug(logPrefix, 'clear: currently updating'); mediaState.waitingToClearBuffer = true; // We can set the offset to zero to remember that this was a call to // clearAllBuffers. mediaState.clearBufferSafeMargin = 0; return; } const type = mediaState.type; if (this.playerInterface_.mediaSourceEngine.bufferStart(type) == null) { // Nothing buffered. shaka.log.debug(logPrefix, 'clear: nothing buffered'); if (mediaState.updateTimer == null) { // Note: an update cycle stops when we buffer to the end of the // presentation, or when we raise an error. this.scheduleUpdate_(mediaState, 0); } return; } // An update may be scheduled, but we can just cancel it and clear the // buffer right away. Note: clearBuffer_() will schedule the next update. shaka.log.debug(logPrefix, 'clear: handling right now'); this.cancelUpdate_(mediaState); this.clearBuffer_(mediaState, /* flush= */ false, 0).catch((error) => { if (this.playerInterface_) { goog.asserts.assert(error instanceof shaka.util.Error, 'Wrong error type!'); this.playerInterface_.onError(error); } }); } /** * Initializes the initial streams and media states. This will schedule * updates for the given types. * * @return {!Promise} * @private */ async initStreams_() { const ContentType = shaka.util.ManifestParserUtils.ContentType; goog.asserts.assert(this.config_, 'StreamingEngine configure() must be called before init()!'); if (!this.currentVariant_) { shaka.log.error('init: no Streams chosen'); throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.STREAMING, shaka.util.Error.Code.STREAMING_ENGINE_STARTUP_INVALID_STATE); } /** * @type {!Map.<shaka.util.ManifestParserUtils.ContentType, * shaka.extern.Stream>} */ const streamsByType = new Map(); /** @type {!Set.<shaka.extern.Stream>} */ const streams = new Set(); if (this.currentVariant_.audio) { streamsByType.set(ContentType.AUDIO, this.currentVariant_.audio); streams.add(this.currentVariant_.audio); } if (this.currentVariant_.video) { streamsByType.set(ContentType.VIDEO, this.currentVariant_.video); streams.add(this.currentVariant_.video); } if (this.currentTextStream_) { streamsByType.set(ContentType.TEXT, this.currentTextStream_); streams.add(this.currentTextStream_); } // Init MediaSourceEngine. const mediaSourceEngine = this.playerInterface_.mediaSourceEngine; await mediaSourceEngine.init(streamsByType, this.manifest_.sequenceMode, this.manifest_.type, this.manifest_.ignoreManifestTimestampsInSegmentsMode, ); this.destroyer_.ensureNotDestroyed(); this.updateDuration(); for (const type of streamsByType.keys()) { const stream = streamsByType.get(type); if (!this.mediaStates_.has(type)) { const mediaState = this.createMediaState_(stream); this.mediaStates_.set(type, mediaState); this.scheduleUpdate_(mediaState, 0); } } } /** * Creates a media state. * * @param {shaka.extern.Stream} stream * @return {shaka.media.StreamingEngine.MediaState_} * @private */ createMediaState_(stream) { return /** @type {shaka.media.StreamingEngine.MediaState_} */ ({ stream, type: stream.type, segmentIterator: null, segmentPrefetch: this.createSegmentPrefetch_(stream), lastSegmentReference: null, lastInitSegmentReference: null, lastTimestampOffset: null, lastAppendWindowStart: null, lastAppendWindowEnd: null, restoreStreamAfterTrickPlay: null, endOfStream: false, performingUpdate: false, updateTimer: null, waitingToClearBuffer: false, clearBufferSafeMargin: 0, waitingToFlushBuffer: false, clearingBuffer: false, // The playhead might be seeking on startup, if a start time is set, so // start "seeked" as true. seeked: true, needsResync: false, recovering: false, hasError: false, operation: null, }); } /** * Creates a media state. * * @param {shaka.extern.Stream} stream * @return {shaka.media.SegmentPrefetch | null} * @private */ createSegmentPrefetch_(stream) { if ( stream.type !== shaka.util.ManifestParserUtils.ContentType.VIDEO && stream.type !== shaka.util.ManifestParserUtils.ContentType.AUDIO ) { return null; } if (this.config_.segmentPrefetchLimit > 0) { return new shaka.media.SegmentPrefetch( this.config_.segmentPrefetchLimit, stream, (reference, stream, streamDataCallback) => { return this.dispatchFetch_(reference, stream, streamDataCallback); }, ); } return null; } /** * Sets the MediaSource's duration. */ updateDuration() { const duration = this.manifest_.presentationTimeline.getDuration(); if (duration < Infinity) { this.playerInterface_.mediaSourceEngine.setDuration(duration); } else { // Not all platforms support infinite durations, so set a finite duration // so we can append segments and so the user agent can seek. this.playerInterface_.mediaSourceEngine.setDuration(Math.pow(2, 32)); } } /** * Called when |mediaState|'s update timer has expired. * * @param {!shaka.media.StreamingEngine.MediaState_} mediaState * @suppress {suspiciousCode} The compiler assumes that updateTimer can't * change during the await, and so complains about the null check. * @private */ async onUpdate_(mediaState) { this.destroyer_.ensureNotDestroyed(); const logPrefix = shaka.media.StreamingEngine.logPrefix_(mediaState); // Sanity check. goog.asserts.assert( !mediaState.performingUpdate && (mediaState.updateTimer != null), logPrefix + ' unexpected call to onUpdate_()'); if (mediaState.performingUpdate || (mediaState.updateTimer == null)) { return; } goog.asserts.assert( !mediaState.clearingBuffer, logPrefix + ' onUpdate_() should not be called when clearing the buffer'); if (mediaState.clearingBuffer) { return; } mediaState.updateTimer = null; // Handle pending buffer clears. if (mediaState.waitingToClearBuffer) { // Note: clearBuffer_() will schedule the next update. shaka.log.debug(logPrefix, 'skipping update and clearing the buffer'); await this.clearBuffer_( mediaState, mediaState.waitingToFlushBuffer, mediaState.clearBufferSafeMargin); return; } // Make sure the segment index exists. If not, create the segment index. if (!mediaState.stream.segmentIndex) { const thisStream = mediaState.stream; await mediaState.stream.createSegmentIndex(); if (thisStream != mediaState.stream) { // We switched streams while in the middle of this async call to // createSegmentIndex. Abandon this update and schedule a new one if // there's not already one pending. // Releases the segmentIndex of the old stream. if (thisStream.closeSegmentIndex) { goog.asserts.assert(!mediaState.stream.segmentIndex, 'mediastate.stream should not have segmentIndex yet.'); thisStream.closeSegmentIndex(); } if (!mediaState.performingUpdate && !mediaState.updateTimer) { this.scheduleUpdate_(mediaState, 0); } return; } } // Update the MediaState. try { const delay = this.update_(mediaState); if (delay != null) { this.scheduleUpdate_(mediaState, delay); mediaState.hasError = false; } } catch (error) { await this.handleStreamingError_(mediaState, error); return; } const mediaStates = Array.from(this.mediaStates_.values()); // Check if we've buffered to the end of the presentation. We delay adding // the audio and video media states, so it is possible for the text stream // to be the only state and buffer to the end. So we need to wait until we // have completed startup to determine if we have reached the end. if (this.startupComplete_ && mediaStates.every((ms) => ms.endOfStream)) { shaka.log.v1(logPrefix, 'calling endOfStream()...'); await this.playerInterface_.mediaSourceEngine.endOfStream(); this.destroyer_.ensureNotDestroyed(); // If the media segments don't reach the end, then we need to update the // timeline duration to match the final media duration to avoid // buffering forever at the end. // We should only do this if the duration needs to shrink. // Growing it by less than 1ms can actually cause buffering on // replay, as in https://github.com/shaka-project/shaka-player/issues/979 // On some platforms, this can spuriously be 0, so ignore this case. // https://github.com/shaka-project/shaka-player/issues/1967, const duration = this.playerInterface_.mediaSourceEngine.getDuration(); if (duration != 0 && duration < this.manifest_.presentationTimeline.getDuration()) { this.manifest_.presentationTimeline.setDuration(duration); } } } /** * Updates the given MediaState. * * @param {shaka.media.StreamingEngine.MediaState_} mediaState * @return {?number} The number of seconds to wait until updating again or * null if another update does not need to be scheduled. * @private */ update_(mediaState) { goog.asserts.assert(this.manifest_, 'manifest_ should not be null'); goog.asserts.assert(this.config_, 'config_ should not be null'); const ContentType = shaka.util.ManifestParserUtils.ContentType; // Do not schedule update for closed captions text mediastate, since closed // captions are embedded in video streams. if (shaka.media.StreamingEngine.isEmbeddedText_(mediaState)) { this.playerInterface_.mediaSourceEngine.setSelectedClosedCaptionId( mediaState.stream.originalId || ''); return null; } else if (mediaState.type == ContentType.TEXT) { // Disable embedded captions if not desired (e.g. if transitioning from // embedded to not-embedded captions). this.playerInterface_.mediaSourceEngine.clearSelectedClosedCaptionId(); } const logPrefix = shaka.media.StreamingEngine.logPrefix_(mediaState); // Compute how far we've buffered ahead of the playhead. const presentationTime = this.playerInterface_.getPresentationTime(); // Get the next timestamp we need. const timeNeeded = this.getTimeNeeded_(mediaState, presentationTime); shaka.log.v2(logPrefix, 'timeNeeded=' + timeNeeded); // Get the amount of content we have buffered, accounting for drift. This // is only used to determine if we have meet the buffering goal. This // should be the same method that PlayheadObserver uses. const bufferedAhead = this.playerInterface_.mediaSourceEngine.bufferedAheadOf( mediaState.type, presentationTime); shaka.log.v2(logPrefix, 'update_:', 'presentationTime=' + presentationTime, 'bufferedAhead=' + bufferedAhead); const unscaledBufferingGoal = Math.max( this.manifest_.minBufferTime || 0, this.config_.rebufferingGoal, this.config_.bufferingGoal); const scaledBufferingGoal = unscaledBufferingGoal * this.bufferingGoalScale_; // Check if we've buffered to the end of the presentation. const timeUntilEnd = this.manifest_.presentationTimeline.getDuration() - timeNeeded; const oneMicrosecond = 1e-6; const bufferEnd = this.playerInterface_.mediaSourceEngine.bufferEnd(mediaState.type); if (timeUntilEnd < oneMicrosecond && !!bufferEnd) { // We shouldn't rebuffer if the playhead is close to the end of the // presentation. shaka.log.debug(logPrefix, 'buffered to end of presentation'); mediaState.endOfStream = true; if (mediaState.type == ContentType.VIDEO) { // Since the text stream of CEA closed captions doesn't have update // timer, we have to set the text endOfStream based on the video // stream's endOfStream state. const textState = this.mediaStates_.get(ContentType.TEXT); if (textState && shaka.media.StreamingEngine.isEmbeddedText_(textState)) { textState.endOfStream = true; } } return null; } mediaState.endOfStream = false; // If we've buffered to the buffering goal then schedule an update. if (bufferedAhead >= scaledBufferingGoal) { shaka.log.v2(logPrefix, 'buffering goal met'); // Do not try to predict the next update. Just poll according to // configuration (seconds). The playback rate can change at any time, so // any prediction we make now could be terribly invalid soon. return this.config_.updateIntervalSeconds / 2; } const reference = this.getSegmentReferenceNeeded_( mediaState, presentationTime, bufferEnd); if (!reference) { // The segment could not be found, does not exist, or is not available. // In any case just try again... if the manifest is incomplete or is not // being updated then we'll idle forever; otherwise, we'll end up getting // a SegmentReference eventually. return this.config_.updateIntervalSeconds; } // Do not let any one stream get far ahead of any other. let minTimeNeeded = Infinity; const mediaStates = Array.from(this.mediaStates_.values()); for (const otherState of mediaStates) { // Do not consider embedded captions in this calculation. It could lead // to hangs in streaming. if (shaka.media.StreamingEngine.isEmbeddedText_(otherState)) { continue; } // If there is no next segment, ignore this stream. This happens with // text when there's a Period with no text in it. if (otherState.segmentIterator && !otherState.segmentIterator.current()) { continue; } const timeNeeded = this.getTimeNeeded_(otherState, presentationTime); minTimeNeeded = Math.min(minTimeNeeded, timeNeeded); } const maxSegmentDuration = this.manifest_.presentationTimeline.getMaxSegmentDuration(); const maxRunAhead = maxSegmentDuration * shaka.media.StreamingEngine.MAX_RUN_AHEAD_SEGMENTS_; if (timeNeeded >= minTimeNeeded + maxRunAhead) { // Wait and give other media types time to catch up to this one. // For example, let video buffering catch up to audio buffering before // fetching another audio segment. shaka.log.v2(logPrefix, 'waiting for other streams to buffer'); return this.config_.updateIntervalSeconds; } if (mediaState.segmentPrefetch && mediaState.segmentIterator) { mediaState.segmentPrefetch.prefetchSegments(reference); } const p = this.fetchAndAppend_(mediaState, presentationTime, reference); p.catch(() => {}); // TODO(#1993): Handle asynchronous errors. return null; } /** * Gets the next timestamp needed. Returns the playhead's position if the * buffer is empty; otherwise, returns the time at which the last segment * appended ends. * * @param {shaka.media.StreamingEngine.MediaState_} mediaState * @param {number} presentationTime * @return {number} The next timestamp needed. * @private */ getTimeNeeded_(mediaState, presentationTime) { // Get the next timestamp we need. We must use |lastSegmentReference| // to determine this and not the actual buffer for two reasons: // 1. Actual segments end slightly before their advertised end times, so // the next timestamp we need is actually larger than |bufferEnd|. // 2. There may be drift (the timestamps in the segments are ahead/behind // of the timestamps in the manifest), but we need drift-free times // when comparing times against the presentation timeline. if (!mediaState.lastSegmentReference) { return presentationTime; } return mediaState.lastSegmentReference.endTime; } /** * Gets the SegmentReference of the next segment needed. * * @param {shaka.media.StreamingEngine.MediaState_} mediaState * @param {number} presentationTime * @param {?number} bufferEnd * @return {shaka.media.SegmentReference} The SegmentReference of the * next segment needed. Returns null if a segment could not be found, does * not exist, or is not available. * @private */ getSegmentReferenceNeeded_(mediaState, presentationTime, bufferEnd) { const logPrefix = shaka.media.StreamingEngine.logPrefix_(mediaState); goog.asserts.assert( mediaState.stream.segmentIndex, 'segment index should have been generated already'); if (mediaState.segmentIterator) { // Something is buffered from the same Stream. Use the current position // in the segment index. This is updated via next() after each segment is // appended. return mediaState.segmentIterator.current(); } else if (mediaState.lastSegmentReference || bufferEnd) { // Something is buffered from another Stream. const time = mediaState.lastSegmentReference ? mediaState.lastSegmentReference.endTime : bufferEnd; goog.asserts.assert(time != null, 'Should have a time to search'); shaka.log.v1( logPrefix, 'looking up segment from new stream endTime:', time); // Using a new iterator means we need to resync the stream in sequence // mode. The buffered range might not align perfectly with the last // segment end time, so we may end up repeating a segment. Resyncing // makes this safe to do. mediaState.needsResync = true; mediaState.segmentIterator = mediaState.stream.segmentIndex.getIteratorForTime(time); const ref = mediaState.segmentIterator && mediaState.segmentIterator.next().value; if (ref == null) { shaka.log.warning(logPrefix, 'cannot find segment', 'endTime:', time); } return ref; } else { // Nothing is buffered. Start at the playhead time. // If there's positive drift then we need to adjust the lookup time, and // may wind up requesting the previous segment to be safe. // inaccurateManifestTolerance should be 0 for low latency streaming. const inaccurateTolerance = this.config_.inaccurateManifestTolerance; const lookupTime = Math.max(presentationTime - inaccurateTolerance, 0); shaka.log.v1(logPrefix, 'looking up segment', 'lookupTime:', lookupTime, 'presentationTime:', presentationTime); let ref = null; if (inaccurateTolerance) { mediaState.segmentIterator = mediaState.stream.segmentIndex.getIteratorForTime(lookupTime); ref = mediaState.segmentIterator && mediaState.segmentIterator.next().value; } if (!ref) { // If we can't find a valid segment with the drifted time, look for a // segment with the presentation time. mediaState.segmentIterator = mediaState.stream.segmentIndex.getIteratorForTime(presentationTime); ref = mediaState.segmentIterator && mediaState.segmentIterator.next().value; } if (ref == null) { shaka.log.warning(logPrefix, 'cannot find segment', 'lookupTime:', lookupTime, 'presentationTime:', presentationTime); } return ref; } } /** * Fetches and appends the given segment. Sets up the given MediaState's * associated SourceBuffer and evicts segments if either are required * beforehand. Schedules another update after completing successfully. * * @param {!shaka.media.StreamingEngine.MediaState_} mediaState * @param {number} presentationTime * @param {!shaka.media.SegmentReference} reference * @private */ async fetchAndAppend_(mediaState, presentationTime, reference) { const ContentType = shaka.util.ManifestParserUtils.ContentType; const StreamingEngine = shaka.media.StreamingEngine; const logPrefix = StreamingEngine.logPrefix_(mediaState); shaka.log.v1(logPrefix, 'fetchAndAppend_:', 'presentationTime=' + presentationTime, 'reference.startTime=' + reference.startTime, 'reference.endTime=' + reference.endTime); // Subtlety: The playhead may move while asynchronous update operations are // in progress, so we should avoid calling playhead.getTime() in any // callbacks. Furthermore, switch() or seeked() may be called at any time, // so we store the old iterator. This allows the mediaState to change and // we'll update the old iterator. const stream = mediaState.stream; const iter = mediaState.segmentIterator; mediaState.performingUpdate = true; try { if (reference.getStatus() == shaka.media.SegmentReference.Status.MISSING) { throw new shaka.util.Error( shaka.util.Error.Severity.RECOVERABLE, shaka.util.Error.Category.NETWORK, shaka.util.Error.Code.SEGMENT_MISSING); } await this.initSourceBuffer_(mediaState, reference); this.destroyer_.ensureNotDestroyed(); if (this.fatalError_) { return; } shaka.log.v2(logPrefix, 'fetching segment'); const isMP4 = stream.mimeType == 'video/mp4' || stream.mimeType == 'audio/mp4'; const isReadableStreamSupported = window.ReadableStream; // Enable MP4 low latency streaming with ReadableStream chunked data. // And only for DASH and HLS with byterange optimization. if (this.config_.lowLatencyMode && isReadableStreamSupported && isMP4 && (this.manifest_.type != shaka.media.ManifestParser.HLS || reference.hasByterangeOptimization())) { let remaining = new Uint8Array(0); let processingResult = false; let callbackCalled = false; const streamDataCallback = async (data) => { if (processingResult) { // If the fallback result processing was triggered, don't also // append the buffer here. In theory this should never happen, // but it does on some older TVs. return; } callbackCalled = true; this.destroyer_.ensureNotDestroyed(); if (this.fatalError_) { return; } // Append the data with complete boxes. // Every time streamDataCallback gets called, append the new data to // the remaining data. // Find the last fully completed Mdat box, and slice the data into two // parts: the first part with completed Mdat boxes, and the second // part with an incomplete box. // Append the first part, and save the second part as remaining data, // and handle it with the next streamDataCallback call. remaining = this.concatArray_(remaining, data); let sawMDAT = false; let offset = 0; new shaka.util.Mp4Parser() .box('mdat', (box) => { offset = box.size + box.start; sawMDAT = true; }) .parse(remaining, /* partialOkay= */ false, /* isChunkedData= */ true); if (sawMDAT) { const dataToAppend = remaining.subarray(0, offset); remaining = remaining.subarray(offset); await this.append_( mediaState, presentationTime, stream, reference, dataToAppend, /* isChunkedData= */ true); } }; const result = await this.fetch_(mediaState, reference, streamDataCallback); if (!callbackCalled) { // In some environments, we might be forced to use network plugins // that don't support streamDataCallback. In those cases, as a // fallback, append the buffer here. processingResult = true; this.destroyer_.ensureNotDestroyed(); if (this.fatalError_) { return; } // If the text stream gets switched between fetch_() and append_(), // the new text parser is initialized, but the new init segment is // not fetched yet. That would cause an error in // TextParser.parseMedia(). // See http://b/168253400 if (mediaState.waitingToClearBuffer) { shaka