UNPKG

shaka-player

Version:
1,438 lines (1,254 loc) 125 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview */ goog.provide('shaka.media.StreamingEngine'); goog.require('goog.asserts'); goog.require('shaka.config.CrossBoundaryStrategy'); goog.require('shaka.log'); goog.require('shaka.media.Capabilities'); goog.require('shaka.media.InitSegmentReference'); goog.require('shaka.media.ManifestParser'); goog.require('shaka.media.MediaSourceEngine'); goog.require('shaka.media.MetaSegmentIndex'); goog.require('shaka.media.SegmentIterator'); goog.require('shaka.media.SegmentReference'); goog.require('shaka.media.SegmentPrefetch'); goog.require('shaka.media.SegmentUtils'); 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.LanguageUtils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Mp4BoxParsers'); goog.require('shaka.util.Mp4Parser'); goog.require('shaka.util.Networking'); goog.require('shaka.util.Timer'); /** * @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; /** * Retains a reference to the function used to close SegmentIndex objects * for streams which were switched away from during an ongoing update_(). * @private {!Map<string, !function()>} */ this.deferredCloseSegmentIndex_ = new Map(); /** @private {number} */ this.bufferingScale_ = 1; /** @private {?shaka.extern.Variant} */ this.currentVariant_ = null; /** @private {?shaka.extern.Stream} */ this.currentTextStream_ = null; /** @private {number} */ this.textStreamSequenceId_ = 0; /** * 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_()); /** @private {number} */ this.lastMediaSourceReset_ = Date.now() / 1000; /** * @private {!Map<shaka.extern.Stream, !shaka.media.SegmentPrefetch>} */ this.audioPrefetchMap_ = new Map(); /** @private {!shaka.extern.SpatialVideoInfo} */ this.spatialVideoInfo_ = { projection: null, hfov: null, }; /** @private {number} */ this.playRangeStart_ = 0; /** @private {number} */ this.playRangeEnd_ = Infinity; /** @private {?shaka.media.StreamingEngine.MediaState_} */ this.lastTextMediaStateBeforeUnload_ = null; /** @private {?shaka.util.Timer} */ this.updateLiveSeekableRangeTime_ = new shaka.util.Timer(() => { if (!this.manifest_ || !this.playerInterface_) { return; } if (!this.manifest_.presentationTimeline.isLive()) { this.playerInterface_.mediaSourceEngine.clearLiveSeekableRange(); if (this.updateLiveSeekableRangeTime_) { this.updateLiveSeekableRangeTime_.stop(); } return; } const startTime = this.manifest_.presentationTimeline.getSeekRangeStart(); const endTime = this.manifest_.presentationTimeline.getSeekRangeEnd(); // Some older devices require the range to be greater than 1 or exceptions // are thrown, due to an old and buggy implementation. if (endTime - startTime > 1) { this.playerInterface_.mediaSourceEngine.setLiveSeekableRange( startTime, endTime); } else { this.playerInterface_.mediaSourceEngine.clearLiveSeekableRange(); } }); /** @private {?number} */ this.boundaryTime_ = null; /** @private {?shaka.util.Timer} */ this.crossBoundaryTimer_ = new shaka.util.Timer(() => { const video = this.playerInterface_.video; if (video.ended) { return; } if (this.boundaryTime_) { shaka.log.info('Crossing boundary at', this.boundaryTime_); video.currentTime = this.boundaryTime_; this.boundaryTime_ = null; } }); } /** @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)); if (state.segmentPrefetch) { state.segmentPrefetch.clearAll(); state.segmentPrefetch = null; } } for (const prefetch of this.audioPrefetchMap_.values()) { prefetch.clearAll(); } await Promise.all(aborts); this.mediaStates_.clear(); this.audioPrefetchMap_.clear(); this.playerInterface_ = null; this.manifest_ = null; this.config_ = null; if (this.updateLiveSeekableRangeTime_) { this.updateLiveSeekableRangeTime_.stop(); } this.updateLiveSeekableRangeTime_ = null; if (this.crossBoundaryTimer_) { this.crossBoundaryTimer_.stop(); } this.crossBoundaryTimer_ = null; this.boundaryTime_ = 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); const ContentType = shaka.util.ManifestParserUtils.ContentType; // disable audio segment prefetch if this is now set if (config.disableAudioPrefetch) { const state = this.mediaStates_.get(ContentType.AUDIO); if (state && state.segmentPrefetch) { state.segmentPrefetch.clearAll(); state.segmentPrefetch = null; } for (const stream of this.audioPrefetchMap_.keys()) { const prefetch = this.audioPrefetchMap_.get(stream); prefetch.clearAll(); this.audioPrefetchMap_.delete(stream); } } // disable text segment prefetch if this is now set if (config.disableTextPrefetch) { const state = this.mediaStates_.get(ContentType.TEXT); if (state && state.segmentPrefetch) { state.segmentPrefetch.clearAll(); state.segmentPrefetch = null; } } // disable video segment prefetch if this is now set if (config.disableVideoPrefetch) { const state = this.mediaStates_.get(ContentType.VIDEO); if (state && state.segmentPrefetch) { state.segmentPrefetch.clearAll(); state.segmentPrefetch = null; } } // 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.clearAll(); state.segmentPrefetch = null; } } else if (config.segmentPrefetchLimit > 0) { state.segmentPrefetch = this.createSegmentPrefetch_(state.stream); } } if (!config.disableAudioPrefetch) { this.updatePrefetchMapForAudio_(); } } /** * Applies a playback range. This will only affect non-live content. * * @param {number} playRangeStart * @param {number} playRangeEnd */ applyPlayRange(playRangeStart, playRangeEnd) { if (!this.manifest_.presentationTimeline.isLive()) { this.playRangeStart_ = playRangeStart; this.playRangeEnd_ = playRangeEnd; } } /** * 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. * * @param {!Map<number, shaka.media.SegmentPrefetch>=} segmentPrefetchById * If provided, segments prefetched for these streams will be used as needed * during playback. * @return {!Promise} */ async start(segmentPrefetchById) { 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_(segmentPrefetchById || (new Map())); 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.lastTextMediaStateBeforeUnload_ = this.mediaStates_.get(ContentType.TEXT); 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; this.updateSegmentIteratorReverse_(); 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.lastTextMediaStateBeforeUnload_ = null; 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); } } /** * Handles deferred releases of old SegmentIndexes for the mediaState's * content type from a previous update. * @param {!shaka.media.StreamingEngine.MediaState_} mediaState * @private */ handleDeferredCloseSegmentIndexes_(mediaState) { for (const [key, value] of this.deferredCloseSegmentIndex_.entries()) { const streamId = /** @type {string} */ (key); const closeSegmentIndex = /** @type {!function()} */ (value); if (streamId.includes(mediaState.type)) { closeSegmentIndex(); this.deferredCloseSegmentIndex_.delete(streamId); } } } /** * 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 (this.audioPrefetchMap_.has(stream)) { mediaState.segmentPrefetch = this.audioPrefetchMap_.get(stream); } else 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. // Do not close segment indexes we are prefetching. if (!this.audioPrefetchMap_.has(mediaState.stream)) { if (mediaState.stream.closeSegmentIndex) { if (mediaState.performingUpdate) { const oldStreamTag = shaka.media.StreamingEngine.logPrefix_(mediaState); if (!this.deferredCloseSegmentIndex_.has(oldStreamTag)) { // The ongoing update is still using the old stream's segment // reference information. // If we close the old stream now, the update will not complete // correctly. // The next onUpdate_() for this content type will resume the // closeSegmentIndex() operation for the old stream once the ongoing // update has finished, then immediately create a new segment index. this.deferredCloseSegmentIndex_.set( oldStreamTag, mediaState.stream.closeSegmentIndex); } } else { mediaState.stream.closeSegmentIndex(); } } } const shouldResetMediaSource = mediaState.stream.isAudioMuxedInVideo != stream.isAudioMuxedInVideo; mediaState.stream = stream; mediaState.segmentIterator = null; mediaState.adaptation = !!adaptation; if (stream.dependencyStream) { mediaState.dependencyMediaState = this.createMediaState_(stream.dependencyStream); } else { mediaState.dependencyMediaState = null; } const streamTag = shaka.media.StreamingEngine.logPrefix_(mediaState); shaka.log.debug('switch: switching to Stream ' + streamTag); if (shouldResetMediaSource) { this.resetMediaSource(/* force= */ true, /* clearBuffer= */ false); return; } 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); } }); } } else { if (!mediaState.performingUpdate && !mediaState.updateTimer) { this.scheduleUpdate_(mediaState, 0); } } 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 = 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); if (!newTimeIsBuffered(type)) { this.lastMediaSourceReset_ = 0; if (mediaState.segmentPrefetch) { mediaState.segmentPrefetch.resetPosition(); } if (mediaState.type === ContentType.AUDIO) { for (const prefetch of this.audioPrefetchMap_.values()) { prefetch.resetPosition(); } } mediaState.segmentIterator = null; 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; } } const CrossBoundaryStrategy = shaka.config.CrossBoundaryStrategy; if (this.config_.crossBoundaryStrategy !== CrossBoundaryStrategy.KEEP) { // We might have seeked near a boundary, forward time in case MSE does not // recover due to segment misalignment near the boundary. this.forwardTimeForCrossBoundary(); } 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. * * @param {!Map<number, shaka.media.SegmentPrefetch>} segmentPrefetchById * @return {!Promise} * @private */ async initStreams_(segmentPrefetchById) { 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); if (segmentPrefetchById.has(stream.id)) { const segmentPrefetch = segmentPrefetchById.get(stream.id); segmentPrefetch.replaceFetchDispatcher( (reference, stream, streamDataCallback) => { return this.dispatchFetch_( reference, stream, streamDataCallback); }); mediaState.segmentPrefetch = segmentPrefetch; } 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) { /** @type {!shaka.media.StreamingEngine.MediaState_} */ const mediaState = { stream, type: /** @type {shaka.util.ManifestParserUtils.ContentType} */( stream.type), segmentIterator: null, segmentPrefetch: this.createSegmentPrefetch_(stream), lastSegmentReference: null, lastInitSegmentReference: null, lastTimestampOffset: null, lastAppendWindowStart: null, lastAppendWindowEnd: null, lastCodecs: null, lastMimeType: 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, adaptation: false, recovering: false, hasError: false, operation: null, dependencyMediaState: null, }; if (stream.dependencyStream) { mediaState.dependencyMediaState = this.createMediaState_(stream.dependencyStream); } return mediaState; } /** * Creates a media state. * * @param {shaka.extern.Stream} stream * @return {shaka.media.SegmentPrefetch | null} * @private */ createSegmentPrefetch_(stream) { const ContentType = shaka.util.ManifestParserUtils.ContentType; if (stream.type === ContentType.VIDEO && this.config_.disableVideoPrefetch) { return null; } if (stream.type === ContentType.AUDIO && this.config_.disableAudioPrefetch) { return null; } const MimeUtils = shaka.util.MimeUtils; const CEA608_MIME = MimeUtils.CEA608_CLOSED_CAPTION_MIMETYPE; const CEA708_MIME = MimeUtils.CEA708_CLOSED_CAPTION_MIMETYPE; if (stream.type === ContentType.TEXT && (stream.mimeType == CEA608_MIME || stream.mimeType == CEA708_MIME)) { return null; } if (stream.type === ContentType.TEXT && this.config_.disableTextPrefetch) { return null; } if (this.audioPrefetchMap_.has(stream)) { return this.audioPrefetchMap_.get(stream); } const type = /** @type {!shaka.util.ManifestParserUtils.ContentType} */ (stream.type); const mediaState = this.mediaStates_.get(type); const currentSegmentPrefetch = mediaState && mediaState.segmentPrefetch; if (currentSegmentPrefetch && stream === currentSegmentPrefetch.getStream()) { return currentSegmentPrefetch; } if (this.config_.segmentPrefetchLimit > 0) { const reverse = this.playerInterface_.getPlaybackRate() < 0; return new shaka.media.SegmentPrefetch( this.config_.segmentPrefetchLimit, stream, (reference, stream, streamDataCallback) => { return this.dispatchFetch_(reference, stream, streamDataCallback); }, reverse); } return null; } /** * Populates the prefetch map depending on the configuration * @private */ updatePrefetchMapForAudio_() { const prefetchLimit = this.config_.segmentPrefetchLimit; const prefetchLanguages = this.config_.prefetchAudioLanguages; const LanguageUtils = shaka.util.LanguageUtils; for (const variant of this.manifest_.variants) { if (!variant.audio) { continue; } if (this.audioPrefetchMap_.has(variant.audio)) { // if we already have a segment prefetch, // update it's prefetch limit and if the new limit isn't positive, // remove the segment prefetch from our prefetch map. const prefetch = this.audioPrefetchMap_.get(variant.audio); prefetch.resetLimit(prefetchLimit); if (!(prefetchLimit > 0) || !prefetchLanguages.some( (lang) => LanguageUtils.areLanguageCompatible( variant.audio.language, lang)) ) { const type = /** @type {!shaka.util.ManifestParserUtils.ContentType}*/ (variant.audio.type); const mediaState = this.mediaStates_.get(type); const currentSegmentPrefetch = mediaState && mediaState.segmentPrefetch; // if this prefetch isn't the current one, we want to clear it if (prefetch !== currentSegmentPrefetch) { prefetch.clearAll(); } this.audioPrefetchMap_.delete(variant.audio); } continue; } // don't try to create a new segment prefetch if the limit isn't positive. if (prefetchLimit <= 0) { continue; } // only create a segment prefetch if its language is configured // to be prefetched if (!prefetchLanguages.some( (lang) => LanguageUtils.areLanguageCompatible( variant.audio.language, lang))) { continue; } // use the helper to create a segment prefetch to ensure that existing // objects are reused. const segmentPrefetch = this.createSegmentPrefetch_(variant.audio); // if a segment prefetch wasn't created, skip the rest if (!segmentPrefetch) { continue; } if (!variant.audio.segmentIndex) { variant.audio.createSegmentIndex(); } this.audioPrefetchMap_.set(variant.audio, segmentPrefetch); } } /** * Sets the MediaSource's duration. */ updateDuration() { const isInfiniteLiveStreamDurationSupported = shaka.media.Capabilities.isInfiniteLiveStreamDurationSupported(); const duration = this.manifest_.presentationTimeline.getDuration(); if (duration < Infinity) { if (isInfiniteLiveStreamDurationSupported) { if (this.updateLiveSeekableRangeTime_) { this.updateLiveSeekableRangeTime_.stop(); } this.playerInterface_.mediaSourceEngine.clearLiveSeekableRange(); } this.playerInterface_.mediaSourceEngine.setDuration(duration); } else { // Set the media source live duration as Infinity if the platform supports // it. if (isInfiniteLiveStreamDurationSupported) { if (this.updateLiveSeekableRangeTime_) { this.updateLiveSeekableRangeTime_.tickEvery(/* seconds= */ 0.5); } this.playerInterface_.mediaSourceEngine.setDuration(Infinity); } 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; } // If stream switches happened during the previous update_() for this // content type, close out the old streams that were switched away from. // Even if we had switched away from the active stream 'A' during the // update_(), e.g. (A -> B -> A), closing 'A' is permissible here since we // will immediately re-create it in the logic below. this.handleDeferredCloseSegmentIndexes_(mediaState); // Make sure the segment index exists. If not, create the segment index. if (!mediaState.stream.segmentIndex) { const thisStream = mediaState.stream; try { await mediaState.stream.createSegmentIndex(); } catch (error) { await this.handleStreamingError_(mediaState, error); return; } 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(); } if (mediaState.stream.isAudioMuxedInVideo) { return null; } // Update updateIntervalSeconds according to our current playbackrate, // and always considering a minimum of 1. const updateIntervalSeconds = this.config_.updateIntervalSeconds / Math.max(1, Math.abs(this.playerInterface_.getPlaybackRate())); if (!this.playerInterface_.mediaSourceEngine.isStreamingAllowed() && mediaState.type != ContentType.TEXT) { // It is not allowed to add segments yet, so we schedule an update to // check again later. So any prediction we make now could be terribly // invalid soon. return updateIntervalSeconds / 2; } const logPrefix = shaka.media.StreamingEngine.logPrefix_(mediaState); // Compute how far we've buffered ahead of the playhead. const presentationTime = this.playerInterface_.getPresentationTime(); if (mediaState.type === ContentType.AUDIO) { // evict all prefetched segments that are before the presentationTime for (const stream of this.a