UNPKG

shaka-player

Version:
1,352 lines (1,172 loc) 75.2 kB
/** * @license * Copyright 2016 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ goog.provide('shaka.media.StreamingEngine'); goog.require('goog.asserts'); goog.require('shaka.log'); goog.require('shaka.media.MediaSourceEngine'); goog.require('shaka.media.Playhead'); goog.require('shaka.net.Backoff'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.util.Error'); goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.Functional'); goog.require('shaka.util.IDestroyable'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MapUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Mp4Parser'); goog.require('shaka.util.PublicPromise'); goog.require('shaka.util.StreamUtils'); /** * Creates a StreamingEngine. * * 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, * and for handling Period transitions. The StreamingEngine provides an * interface to switch between Streams, but it does not choose which Streams to * switch to. * * The StreamingEngine notifies its owner when it needs to buffer a new Period, * so its owner can choose which Streams within that Period to initially * buffer. Moreover, the StreamingEngine also notifies its owner when any * Stream within the current Period may be switched to, so its owner can switch * bitrates, resolutions, or languages. * * The StreamingEngine does not need to be notified about changes to the * Manifest's SegmentIndexes; however, it does need to be notified when new * Periods are added to the Manifest, so it can set up that Period's Streams. * * To start the StreamingEngine the owner must first call configure() followed * by init(). The StreamingEngine will then call onChooseStreams(p) when it * needs to buffer Period p; it will then switch to the Streams returned from * that function. The StreamingEngine will call onCanSwitch() when any * Stream within the current Period may be switched to. * * 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. * * @param {shakaExtern.Manifest} manifest * @param {shaka.media.StreamingEngine.PlayerInterface} playerInterface * * @constructor * @struct * @implements {shaka.util.IDestroyable} */ shaka.media.StreamingEngine = function(manifest, playerInterface) { /** @private {?shaka.media.StreamingEngine.PlayerInterface} */ this.playerInterface_ = playerInterface; /** @private {?shakaExtern.Manifest} */ this.manifest_ = manifest; /** @private {?shakaExtern.StreamingConfiguration} */ this.config_ = null; /** @private {number} */ this.bufferingGoalScale_ = 1; /** @private {Promise} */ this.setupPeriodPromise_ = Promise.resolve(); /** * Maps a Period's index to an object that indicates that either * 1. the Period has not been set up (undefined) * 2. the Period is being set up ([a PublicPromise, false]), * 3. the Period is set up (i.e., all Streams within the Period are set up) * and can be switched to ([a PublicPromise, true]). * * @private {Array.<?{promise: shaka.util.PublicPromise, resolved: boolean}>} */ this.canSwitchPeriod_ = []; /** * Maps a Stream's ID to an object that indicates that either * 1. the Stream has not been set up (undefined) * 2. the Stream is being set up ([a Promise instance, false]), * 3. the Stream is set up and can be switched to * ([a Promise instance, true]). * * @private {Object.<number, * ?{promise: shaka.util.PublicPromise, resolved: boolean}>} */ this.canSwitchStream_ = {}; /** * Maps a content type, e.g., 'audio', 'video', or 'text', to a MediaState. * * @private {Object.<shaka.util.ManifestParserUtils.ContentType, !shaka.media.StreamingEngine.MediaState_>} */ this.mediaStates_ = {}; /** * Set to true once one segment of each content type has been buffered. * * @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 {boolean} */ this.destroyed_ = false; }; /** * @typedef {{ * variant: (?shakaExtern.Variant|undefined), * text: ?shakaExtern.Stream * }} * * @property {(?shakaExtern.Variant|undefined)} variant * The chosen variant. May be omitted for text re-init. * @property {?shakaExtern.Stream} text * The chosen text stream. */ shaka.media.StreamingEngine.ChosenStreams; /** * @typedef {{ * playhead: !shaka.media.Playhead, * mediaSourceEngine: !shaka.media.MediaSourceEngine, * netEngine: shaka.net.NetworkingEngine, * onChooseStreams: function(!shakaExtern.Period): * shaka.media.StreamingEngine.ChosenStreams, * onCanSwitch: function(), * onError: function(!shaka.util.Error), * onEvent: function(!Event), * onManifestUpdate: function(), * onSegmentAppended: function(), * onInitialStreamsSetup: (function()|undefined), * onStartupComplete: (function()|undefined)} * }} * * @property {!shaka.media.Playhead} playhead * The Playhead. The caller retains ownership. * @property {!shaka.media.MediaSourceEngine} mediaSourceEngine * The MediaSourceEngine. The caller retains ownership. * @property {shaka.net.NetworkingEngine} netEngine * The NetworkingEngine instance to use. The caller retains ownership. * @property {function(!shakaExtern.Period): * shaka.media.StreamingEngine.ChosenStreams} onChooseStreams * Called by StreamingEngine when the given Period needs to be buffered. * StreamingEngine will switch to the variant and text stream returned from * this function. * The owner cannot call switch() directly until the StreamingEngine calls * onCanSwitch(). * @property {function()} onCanSwitch * Called by StreamingEngine when the Period is set up and switching is * permitted. * @property {function(!shaka.util.Error)} onError * Called when an error occurs. If the error is recoverable (see * @link{shaka.util.Error}) then the caller may invoke either * StreamingEngine.switch*() or StreamingEngine.seeked() to attempt recovery. * @property {function(!Event)} onEvent * Called when an event occurs that should be sent to the app. * @property {function()} onManifestUpdate * Called when an embedded 'emsg' box should trigger a manifest update. * @property {function()} onSegmentAppended * Called after a segment is successfully appended to a MediaSource. * @property {(function()|undefined)} onInitialStreamsSetup * Optional callback which is called when the initial set of Streams have been * setup. Intended to be used by tests. * @property {(function()|undefined)} onStartupComplete * Optional callback which is called when startup has completed. Intended to * be used by tests. */ shaka.media.StreamingEngine.PlayerInterface; /** * @typedef {{ * type: shaka.util.ManifestParserUtils.ContentType, * stream: shakaExtern.Stream, * lastStream: ?shakaExtern.Stream, * lastSegmentReference: shaka.media.SegmentReference, * restoreStreamAfterTrickPlay: ?shakaExtern.Stream, * needInitSegment: boolean, * needPeriodIndex: number, * endOfStream: boolean, * performingUpdate: boolean, * updateTimer: ?number, * waitingToClearBuffer: boolean, * waitingToFlushBuffer: boolean, * clearingBuffer: boolean, * recovering: boolean, * hasError: boolean, * resumeAt: number * }} * * @description * Contains the state of a logical stream, i.e., a sequence of segmented data * for a particular content type. At any given time there is a Stream object * associated with the state of the logical stream. * * @property {shaka.util.ManifestParserUtils.ContentType} type * The stream's content type, e.g., 'audio', 'video', or 'text'. * @property {shakaExtern.Stream} stream * The current Stream. * @property {?shakaExtern.Stream} lastStream * The Stream of the last segment that was appended. * @property {shaka.media.SegmentReference} lastSegmentReference * The SegmentReference of the last segment that was appended. * @property {?shakaExtern.Stream} restoreStreamAfterTrickPlay * The Stream to restore after trick play mode is turned off. * @property {boolean} needInitSegment * True indicates that |stream|'s init segment must be inserted before the * next media segment is appended. * @property {boolean} endOfStream * True indicates that the end of the buffer has hit the end of the * presentation. * @property {number} needPeriodIndex * The index of the Period which needs to be buffered. * @property {boolean} performingUpdate * True indicates that an update is in progress. * @property {?number} updateTimer * A non-null value indicates that an update is scheduled. * @property {boolean} waitingToClearBuffer * True indicates that the buffer must be cleared after the current update * finishes. * @property {boolean} waitingToFlushBuffer * True indicates that the buffer must be flushed after it is cleared. * @property {boolean} clearingBuffer * True indicates that the buffer is being cleared. * @property {boolean} recovering * True indicates that the last segment was not appended because it could not * fit in the buffer. * @property {boolean} hasError * True indicates that the stream has encountered an error and has stopped * updates. * @property {number} resumeAt * An override for the time to start performing updates at. If the playhead * is behind this time, update_() will still start fetching segments from * this time. If the playhead is ahead of the time, this field is ignored. */ shaka.media.StreamingEngine.MediaState_; /** * The minimum number seconds that will remain buffered after evicting media. * * @const {number} */ shaka.media.StreamingEngine.prototype.MIN_BUFFER_LENGTH = 2; /** @override */ shaka.media.StreamingEngine.prototype.destroy = function() { for (var type in this.mediaStates_) { this.cancelUpdate_(this.mediaStates_[type]); } this.playerInterface_ = null; this.manifest_ = null; this.setupPeriodPromise_ = null; this.canSwitchPeriod_ = null; this.canSwitchStream_ = null; this.mediaStates_ = null; this.config_ = null; this.destroyed_ = true; return Promise.resolve(); }; /** * Called by the Player to provide an updated configuration any time it changes. * Will be called at least once before init(). * * @param {shakaExtern.StreamingConfiguration} config */ shaka.media.StreamingEngine.prototype.configure = function(config) { this.config_ = config; // Create separate parameters for backoff during streaming failure. /** @type {shakaExtern.RetryParameters} */ var 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 }; // We don't want to ever run out of attempts. The application should be // allowed to retry streaming infinitely if it wishes. var autoReset = true; this.failureCallbackBackoff_ = new shaka.net.Backoff(failureRetryParams, autoReset); }; /** * Initializes the StreamingEngine. * * After this function is called the StreamingEngine will call * onChooseStreams(p) when it needs to buffer Period p and onCanSwitch() when * any Stream within that Period may be switched to. * * After the StreamingEngine calls onChooseStreams(p) for the first time, it * will begin setting up the Streams returned from that function and * subsequently switch to them. However, the StreamingEngine will not begin * setting up any other Streams until at least one segment from each of the * initial set of Streams has been buffered (this reduces startup latency). * After the StreamingEngine completes this startup phase it will begin setting * up each Period's Streams (while buffering in parrallel). * * When the StreamingEngine needs to buffer the next Period it will have * already set up that Period's Streams. So, when the StreamingEngine calls * onChooseStreams(p) after the first time, the StreamingEngine will * immediately switch to the Streams returned from that function. * * @return {!Promise} */ shaka.media.StreamingEngine.prototype.init = function() { goog.asserts.assert(this.config_, 'StreamingEngine configure() must be called before init()!'); // Determine which Period we must buffer. var playheadTime = this.playerInterface_.playhead.getTime(); var needPeriodIndex = this.findPeriodContainingTime_(playheadTime); // Get the initial set of Streams. var initialStreams = this.playerInterface_.onChooseStreams( this.manifest_.periods[needPeriodIndex]); if (!initialStreams.variant && !initialStreams.text) { shaka.log.error('init: no Streams chosen'); return Promise.reject(new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.STREAMING, shaka.util.Error.Code.INVALID_STREAMS_CHOSEN)); } // Setup the initial set of Streams and then begin each update cycle. After // startup completes onUpdate_() will set up the remaining Periods. return this.initStreams_(initialStreams).then(function() { shaka.log.debug('init: completed initial Stream setup'); // Subtlety: onInitialStreamsSetup() may call switch() or seeked(), so we // must schedule an update beforehand so |updateTimer| is set. if (this.playerInterface_ && this.playerInterface_.onInitialStreamsSetup) { shaka.log.v1('init: calling onInitialStreamsSetup()...'); this.playerInterface_.onInitialStreamsSetup(); } }.bind(this)); }; /** * Gets the current Period the stream is in. This Period may not be initialized * yet if canSwitch(period) has not been called yet. * @return {shakaExtern.Period} */ shaka.media.StreamingEngine.prototype.getCurrentPeriod = function() { var playheadTime = this.playerInterface_.playhead.getTime(); var needPeriodIndex = this.findPeriodContainingTime_(playheadTime); return this.manifest_.periods[needPeriodIndex]; }; /** * Gets the Period in which we are currently buffering. This may be different * from the Period which contains the Playhead. * @return {?shakaExtern.Period} */ shaka.media.StreamingEngine.prototype.getActivePeriod = function() { goog.asserts.assert(this.mediaStates_, 'Must be initialized'); var ContentType = shaka.util.ManifestParserUtils.ContentType; var anyMediaState = this.mediaStates_[ContentType.VIDEO] || this.mediaStates_[ContentType.AUDIO]; return anyMediaState ? this.manifest_.periods[anyMediaState.needPeriodIndex] : null; }; /** * Gets a map of all the active streams. * @return {!Object.<shaka.util.ManifestParserUtils.ContentType, * shakaExtern.Stream>} */ shaka.media.StreamingEngine.prototype.getActiveStreams = function() { goog.asserts.assert(this.mediaStates_, 'Must be initialized'); var MapUtils = shaka.util.MapUtils; return MapUtils.map( this.mediaStates_, function(state) { // Don't tell the caller about trick play streams. If we're in trick // play, return the stream we will go back to after we exit trick play. return state.restoreStreamAfterTrickPlay || state.stream; }); }; /** * Notifies StreamingEngine that a new text stream was added to the manifest. * This initializes the given stream. This returns a Promise that resolves when * the stream has been set up. * * @param {shakaExtern.Stream} stream * @return {!Promise} */ shaka.media.StreamingEngine.prototype.notifyNewTextStream = function(stream) { return this.initStreams_({ text: stream }); }; /** * Set trick play on or off. * If trick play is on, related trick play streams will be used when possible. * @param {boolean} on */ shaka.media.StreamingEngine.prototype.setTrickPlay = function(on) { var ContentType = shaka.util.ManifestParserUtils.ContentType; var mediaState = this.mediaStates_[ContentType.VIDEO]; if (!mediaState) return; var stream = mediaState.stream; if (!stream) return; shaka.log.debug('setTrickPlay', on); if (on) { var trickModeVideo = stream.trickModeVideo; if (!trickModeVideo) return; // Can't engage trick play. var normalVideo = mediaState.restoreStreamAfterTrickPlay; if (normalVideo) return; // Already in trick play. shaka.log.debug('Engaging trick mode stream', trickModeVideo); this.switchInternal_(trickModeVideo, false); mediaState.restoreStreamAfterTrickPlay = stream; } else { var normalVideo = mediaState.restoreStreamAfterTrickPlay; if (!normalVideo) return; shaka.log.debug('Restoring non-trick-mode stream', normalVideo); mediaState.restoreStreamAfterTrickPlay = null; this.switchInternal_(normalVideo, true); } }; /** * @param {shakaExtern.Variant} variant * @param {boolean} clearBuffer */ shaka.media.StreamingEngine.prototype.switchVariant = function(variant, clearBuffer) { if (variant.video) { this.switchInternal_(variant.video, clearBuffer); } if (variant.audio) { this.switchInternal_(variant.audio, clearBuffer); } }; /** * @param {shakaExtern.Stream} textStream */ shaka.media.StreamingEngine.prototype.switchTextStream = function(textStream) { goog.asserts.assert(textStream && textStream.type == 'text', 'Wrong stream type passed to switchTextStream!'); this.switchInternal_(textStream, /* clearBuffer */ true); }; /** * Switches to the given Stream. |stream| may be from any Variant or any * Period. * * @param {shakaExtern.Stream} stream * @param {boolean} clearBuffer * @private */ shaka.media.StreamingEngine.prototype.switchInternal_ = function( stream, clearBuffer) { var ContentType = shaka.util.ManifestParserUtils.ContentType; var mediaState = this.mediaStates_[/** @type {!ContentType} */(stream.type)]; if (!mediaState && stream.type == ContentType.TEXT && this.config_.ignoreTextStreamFailures) { this.notifyNewTextStream(stream); return; } goog.asserts.assert(mediaState, 'switch: expected mediaState to exist'); if (!mediaState) return; // If we are selecting a stream from a different Period, then we need to // handle a Period transition. Simply ignore the given stream, assuming that // Player will select the same track in onChooseStreams. var periodIndex = this.findPeriodContainingStream_(stream); if (clearBuffer && periodIndex != mediaState.needPeriodIndex) { shaka.log.debug('switch: switching to stream in another Period; clearing ' + 'buffer and changing Periods'); // handlePeriodTransition_ will be called on the next update because the // current Period won't match the playhead Period. this.clearAllBuffers_(); 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 { // No special trick mode video for this stream! mediaState.restoreStreamAfterTrickPlay = null; shaka.log.debug('switch found no special trick play stream'); } } // Ensure the Period is ready. var canSwitchRecord = this.canSwitchPeriod_[periodIndex]; goog.asserts.assert( canSwitchRecord && canSwitchRecord.resolved, 'switch: expected Period ' + periodIndex + ' to be ready'); if (!canSwitchRecord || !canSwitchRecord.resolved) return; // Sanity check. If the Period is ready then the Stream should be ready too. canSwitchRecord = this.canSwitchStream_[stream.id]; goog.asserts.assert(canSwitchRecord && canSwitchRecord.resolved, 'switch: expected Stream ' + stream.id + ' to be ready'); if (!canSwitchRecord || !canSwitchRecord.resolved) return; if (mediaState.stream == stream) { var streamTag = shaka.media.StreamingEngine.logPrefix_(mediaState); shaka.log.debug('switch: Stream ' + streamTag + ' already active'); return; } 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. var fullMimeType = shaka.util.MimeUtils.getFullType( stream.mimeType, stream.codecs); this.playerInterface_.mediaSourceEngine.reinitText(fullMimeType); } mediaState.stream = stream; mediaState.needInitSegment = true; var 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. mediaState.waitingToClearBuffer = true; mediaState.waitingToFlushBuffer = true; } else { // Cancel the update timer, if any. this.cancelUpdate_(mediaState); // Clear right away. this.clearBuffer_(mediaState, /* flush */ true); } } }; /** * Notifies the StreamingEngine that the playhead has moved to a valid time * within the presentation timeline. */ shaka.media.StreamingEngine.prototype.seeked = function() { goog.asserts.assert(this.mediaStates_, 'Must not be destroyed'); var playheadTime = this.playerInterface_.playhead.getTime(); var isAllBuffered = Object.keys(this.mediaStates_).every(function(type) { return this.playerInterface_.mediaSourceEngine.isBuffered( type, playheadTime); }.bind(this)); // Only treat as a buffered seek if every media state has a buffer. For // example, if we have buffered text but not video, we should still clear // every buffer so all media states need the same Period. if (isAllBuffered) { shaka.log.debug( '(all): seeked: buffered seek: playheadTime=' + playheadTime); return; } // This was an unbuffered seek (for at least one stream), clear all buffers. // Don't clear only some of the buffers because we can become stalled since // the media states are waiting for different Periods. shaka.log.debug('(all): seeked: unbuffered seek: clearing all buffers'); this.clearAllBuffers_(); }; /** * Clears the buffer for every stream. Unlike clearBuffer_, this will handle * cases where a MediaState is performing an update. After this runs, every * MediaState will have a pending update. * @private */ shaka.media.StreamingEngine.prototype.clearAllBuffers_ = function() { for (var type in this.mediaStates_) { var mediaState = this.mediaStates_[type]; var 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'); continue; } if (mediaState.waitingToClearBuffer) { // May not be performing an update, but an update will still happen. // See: https://github.com/google/shaka-player/issues/334 shaka.log.debug(logPrefix, 'clear: already waiting'); continue; } 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; continue; } 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 Period, or when we raise an error. this.scheduleUpdate_(mediaState, 0); } continue; } // 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); } }; /** * Initializes the given streams and media states if required. This will * schedule updates for the given types. * * @param {shaka.media.StreamingEngine.ChosenStreams} chosenStreams * @param {number=} opt_resumeAt * @return {!Promise} * @private */ shaka.media.StreamingEngine.prototype.initStreams_ = function( chosenStreams, opt_resumeAt) { goog.asserts.assert(this.config_, 'StreamingEngine configure() must be called before init()!'); // Determine which Period we must buffer. var playheadTime = this.playerInterface_.playhead.getTime(); var needPeriodIndex = this.findPeriodContainingTime_(playheadTime); // Init/re-init MediaSourceEngine. Note that a re-init is only valid for text. var ContentType = shaka.util.ManifestParserUtils.ContentType; /** @type {!Object.<!ContentType, shakaExtern.Stream>} */ var streamsByType = {}; /** @type {!Array.<shakaExtern.Stream>} */ var streams = []; if (chosenStreams.variant && chosenStreams.variant.audio) { streamsByType[ContentType.AUDIO] = chosenStreams.variant.audio; streams.push(chosenStreams.variant.audio); } if (chosenStreams.variant && chosenStreams.variant.video) { streamsByType[ContentType.VIDEO] = chosenStreams.variant.video; streams.push(chosenStreams.variant.video); } if (chosenStreams.text) { streamsByType[ContentType.TEXT] = chosenStreams.text; streams.push(chosenStreams.text); } // Init MediaSourceEngine. this.playerInterface_.mediaSourceEngine.init(streamsByType); this.setDuration_(); // Setup the initial set of Streams and then begin each update cycle. After // startup completes onUpdate_() will set up the remaining Periods. return this.setupStreams_(streams).then(function() { if (this.destroyed_) return; for (var type in streamsByType) { var stream = streamsByType[type]; if (!this.mediaStates_[type]) { this.mediaStates_[type] = { stream: stream, type: type, lastStream: null, lastSegmentReference: null, restoreStreamAfterTrickPlay: null, needInitSegment: true, needPeriodIndex: needPeriodIndex, endOfStream: false, performingUpdate: false, updateTimer: null, waitingToClearBuffer: false, waitingToFlushBuffer: false, clearingBuffer: false, recovering: false, hasError: false, resumeAt: opt_resumeAt || 0 }; this.scheduleUpdate_(this.mediaStates_[type], 0); } } }.bind(this)); }; /** * Sets up the given Period if necessary. Calls onError() if an error * occurs. * * @param {number} periodIndex The Period's index. * @return {!Promise} A Promise which is resolved when the given Period is * setup. * @private */ shaka.media.StreamingEngine.prototype.setupPeriod_ = function(periodIndex) { var Functional = shaka.util.Functional; var canSwitchRecord = this.canSwitchPeriod_[periodIndex]; if (canSwitchRecord) { shaka.log.debug( '(all) Period ' + periodIndex + ' is being or has been set up'); goog.asserts.assert(canSwitchRecord.promise, 'promise must not be null'); return canSwitchRecord.promise; } shaka.log.debug('(all) setting up Period ' + periodIndex); canSwitchRecord = { promise: new shaka.util.PublicPromise(), resolved: false }; this.canSwitchPeriod_[periodIndex] = canSwitchRecord; var streams = this.manifest_.periods[periodIndex].variants .map(function(variant) { var result = []; if (variant.audio) result.push(variant.audio); if (variant.video) result.push(variant.video); if (variant.video && variant.video.trickModeVideo) result.push(variant.video.trickModeVideo); return result; }) .reduce(Functional.collapseArrays, []) .filter(Functional.isNotDuplicate); // Add text streams streams.push.apply(streams, this.manifest_.periods[periodIndex].textStreams); // Serialize Period set up. this.setupPeriodPromise_ = this.setupPeriodPromise_.then(function() { if (this.destroyed_) return; return this.setupStreams_(streams); }.bind(this)).then(function() { if (this.destroyed_) return; this.canSwitchPeriod_[periodIndex].promise.resolve(); this.canSwitchPeriod_[periodIndex].resolved = true; shaka.log.v1('(all) setup Period ' + periodIndex); }.bind(this)).catch(function(error) { if (this.destroyed_) return; this.canSwitchPeriod_[periodIndex].promise.reject(); delete this.canSwitchPeriod_[periodIndex]; shaka.log.warning('(all) failed to setup Period ' + periodIndex); this.playerInterface_.onError(error); // Don't stop other Periods from being set up. }.bind(this)); return canSwitchRecord.promise; }; /** * Sets up the given Streams if necessary. Does NOT call onError() if an * error occurs. * * @param {!Array.<!shakaExtern.Stream>} streams * @return {!Promise} * @private */ shaka.media.StreamingEngine.prototype.setupStreams_ = function(streams) { // Make sure that all the streams have unique ids. // (Duplicate ids will cause the player to hang). var uniqueStreamIds = streams.map(function(s) { return s.id; }) .filter(shaka.util.Functional.isNotDuplicate); goog.asserts.assert(uniqueStreamIds.length == streams.length, 'streams should have unique ids'); // Parallelize Stream set up. var async = []; for (var i = 0; i < streams.length; ++i) { var stream = streams[i]; var canSwitchRecord = this.canSwitchStream_[stream.id]; if (canSwitchRecord) { shaka.log.debug( '(all) Stream ' + stream.id + ' is being or has been set up'); async.push(canSwitchRecord.promise); } else { shaka.log.v1('(all) setting up Stream ' + stream.id); this.canSwitchStream_[stream.id] = { promise: new shaka.util.PublicPromise(), resolved: false }; async.push(stream.createSegmentIndex()); } } return Promise.all(async).then(function() { if (this.destroyed_) return; for (var i = 0; i < streams.length; ++i) { var stream = streams[i]; var canSwitchRecord = this.canSwitchStream_[stream.id]; if (!canSwitchRecord.resolved) { canSwitchRecord.promise.resolve(); canSwitchRecord.resolved = true; shaka.log.v1('(all) setup Stream ' + stream.id); } } }.bind(this)).catch(function(error) { if (this.destroyed_) return; this.canSwitchStream_[stream.id].promise.reject(); delete this.canSwitchStream_[stream.id]; return Promise.reject(error); }.bind(this)); }; /** * Sets the MediaSource's duration. * @private */ shaka.media.StreamingEngine.prototype.setDuration_ = function() { var 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 * @private */ shaka.media.StreamingEngine.prototype.onUpdate_ = function(mediaState) { var MapUtils = shaka.util.MapUtils; if (this.destroyed_) return; var 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'); this.clearBuffer_(mediaState, mediaState.waitingToFlushBuffer); return; } // Update the MediaState. try { var delay = this.update_(mediaState); if (delay != null) { this.scheduleUpdate_(mediaState, delay); mediaState.hasError = false; } } catch (error) { this.handleStreamingError_(error); return; } goog.asserts.assert(this.mediaStates_, 'must not be destroyed'); var mediaStates = MapUtils.values(this.mediaStates_); // Check if we've buffered to the end of the Period. this.handlePeriodTransition_(mediaState); // Check if we've buffered to the end of the presentation. if (mediaStates.every(function(ms) { return ms.endOfStream; })) { shaka.log.v1(logPrefix, 'calling endOfStream()...'); this.playerInterface_.mediaSourceEngine.endOfStream().then(function() { // 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. var duration = this.playerInterface_.mediaSourceEngine.getDuration(); this.manifest_.presentationTimeline.setDuration(duration); }.bind(this)); } }; /** * 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. * @throws {!shaka.util.Error} if an error occurs. * @private */ shaka.media.StreamingEngine.prototype.update_ = function(mediaState) { var logPrefix = shaka.media.StreamingEngine.logPrefix_(mediaState); // Compute how far we've buffered ahead of the playhead. var playheadTime = this.playerInterface_.playhead.getTime(); // Get the next timestamp we need. var timeNeeded = this.getTimeNeeded_(mediaState, playheadTime); shaka.log.v2(logPrefix, 'timeNeeded=' + timeNeeded); var currentPeriodIndex = this.findPeriodContainingStream_(mediaState.stream); var needPeriodIndex = this.findPeriodContainingTime_(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 way that PlayheadObserver uses. var bufferedAhead = this.playerInterface_.mediaSourceEngine.bufferedAheadOf( mediaState.type, playheadTime); shaka.log.v2(logPrefix, 'update_:', 'playheadTime=' + playheadTime, 'bufferedAhead=' + bufferedAhead); var bufferingGoal = this.getBufferingGoal_(); // Check if we've buffered to the end of the presentation. if (timeNeeded >= this.manifest_.presentationTimeline.getDuration()) { // 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; return null; } mediaState.endOfStream = false; // Check if we've buffered to the end of the Period. This should be done // before checking segment availability because the new Period may become // available once it's switched to. Note that we don't use the non-existence // of SegmentReferences as an indicator to determine Period boundaries // because SegmentIndexes can provide SegmentReferences outside its Period. mediaState.needPeriodIndex = needPeriodIndex; if (needPeriodIndex != currentPeriodIndex) { shaka.log.debug(logPrefix, 'need Period ' + needPeriodIndex, 'playheadTime=' + playheadTime, 'timeNeeded=' + timeNeeded, 'currentPeriodIndex=' + currentPeriodIndex); return null; } // If we've buffered to the buffering goal then schedule an update. if (bufferedAhead >= bufferingGoal) { shaka.log.v2(logPrefix, 'buffering goal met'); // Do not try to predict the next update. Just poll twice every second. // The playback rate can change at any time, so any prediction we make now // could be terribly invalid soon. return 0.5; } var bufferEnd = this.playerInterface_.mediaSourceEngine.bufferEnd(mediaState.type); var reference = this.getSegmentReferenceNeeded_( mediaState, playheadTime, bufferEnd, currentPeriodIndex); 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 1; } mediaState.resumeAt = 0; this.fetchAndAppend_(mediaState, playheadTime, currentPeriodIndex, reference); return null; }; /** * Computes buffering goal. * * @return {number} * @private */ shaka.media.StreamingEngine.prototype.getBufferingGoal_ = function() { goog.asserts.assert(this.manifest_, 'manifest_ should not be null'); goog.asserts.assert(this.config_, 'config_ should not be null'); var rebufferingGoal = shaka.util.StreamUtils.getRebufferingGoal( this.manifest_, this.config_, this.bufferingGoalScale_); return Math.max( rebufferingGoal, this.bufferingGoalScale_ * this.config_.bufferingGoal); }; /** * 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} playheadTime * @return {number} The next timestamp needed. * @throws {!shaka.util.Error} if the buffer is inconsistent with our * expectations. * @private */ shaka.media.StreamingEngine.prototype.getTimeNeeded_ = function( mediaState, playheadTime) { // 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|; and // 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 presentation and Period boundaries. if (!mediaState.lastStream || !mediaState.lastSegmentReference) { return Math.max(playheadTime, mediaState.resumeAt); } var lastPeriodIndex = this.findPeriodContainingStream_(mediaState.lastStream); var lastPeriod = this.manifest_.periods[lastPeriodIndex]; return lastPeriod.startTime + mediaState.lastSegmentReference.endTime; }; /** * Gets the SegmentReference of the next segment needed. * * @param {shaka.media.StreamingEngine.MediaState_} mediaState * @param {number} playheadTime * @param {?number} bufferEnd * @param {number} currentPeriodIndex * @return {shaka.media.SegmentReference} The SegmentReference of the * next segment needed, or null if a segment could not be found, does not * exist, or is not available. * @private */ shaka.media.StreamingEngine.prototype.getSegmentReferenceNeeded_ = function( mediaState, playheadTime, bufferEnd, currentPeriodIndex) { var logPrefix = shaka.media.StreamingEngine.logPrefix_(mediaState); if (mediaState.lastSegmentReference && mediaState.stream == mediaState.lastStream) { // Something is buffered from the same Stream. var position = mediaState.lastSegmentReference.position + 1; shaka.log.v2(logPrefix, 'next position known:', 'position=' + position); return this.getSegmentReferenceIfAvailable_( mediaState, currentPeriodIndex, position); } var position; if (mediaState.lastSegmentReference) { // Something is buffered from another Stream. goog.asserts.assert(mediaState.lastStream, 'lastStream should not be null'); shaka.log.v1(logPrefix, 'next position unknown: another Stream buffered'); var lastPeriodIndex = this.findPeriodContainingStream_(mediaState.lastStream); var lastPeriod = this.manifest_.periods[lastPeriodIndex]; position = this.lookupSegmentPosition_( mediaState, lastPeriod.startTime + mediaState.lastSegmentReference.endTime, currentPeriodIndex); } else { // Either nothing is buffered, or we have cleared part of the buffer. If // we still have some buffered, use that time to find the segment, otherwise // start at the playhead time. goog.asserts.assert(!mediaState.lastStream, 'lastStream should be null'); shaka.log.v1(logPrefix, 'next position unknown: nothing buffered'); position = this.lookupSegmentPosition_( mediaState, bufferEnd || playheadTime, currentPeriodIndex); } if (position == null) return null; var reference = null; if (bufferEnd == null) { // If there's positive drift then we need to get the previous segment; // however, we don't actually know how much drift there is, so we must // unconditionally get the previous segment. If it turns out that there's // non-positive drift then we'll just end up buffering beind the playhead a // little more than we needed. var optimalPosition = Math.max(0, position - 1); reference = this.getSegmentReferenceIfAvailable_( mediaState, currentPeriodIndex, optimalPosition); } return reference || this.getSegmentReferenceIfAvailable_( mediaState, currentPeriodIndex, position); }; /** * Looks up the position of the segment containing the given timestamp. * * @param {shaka.media.StreamingEngine.MediaState_} mediaState * @param {number} presentationTime The timestamp needed, relative to the * start of the presentation. * @param {number} currentPeriodIndex * @return {?number} A segment position, or null if a segment was not be found. * @private */ shaka.media.StreamingEngine.prototype.lookupSegmentPosition_ = function( mediaState, presentationTime, currentPeriodIndex) { var logPrefix = shaka.media.StreamingEngine.logPrefix_(mediaState); var currentPeriod = this.manifest_.periods[currentPeriodIndex]; shaka.log.debug(logPrefix, 'looking up segment:', 'presentationTime=' + presentationTime, 'currentPeriod.startTime=' + currentPeriod.startTime); var lookupTime = Math.max(0, presentationTime - currentPeriod.startTime); var position = mediaState.stream.findSegmentPosition(lookupTime); if (position == null) { shaka.log.warning(logPrefix, 'cannot find segment:', 'currentPeriod.startTime=' + currentPeriod.startTime, 'lookupTime=' + lookupTime); } return position; }; /** * Gets the SegmentReference at the given position if it's available. * * @param {shaka.media.StreamingEngine.MediaState_} mediaState * @param {number} currentPeriodIndex * @param {number} position * @return {shaka.media.SegmentReference} * * @private */ shaka.media.StreamingEngine.prototype.getSegmentReferenceIfAvailable_ = function(mediaState, currentPeriodIndex, position) { var logPrefix = shaka.media.StreamingEngine.logPrefix_(mediaState); var currentPeriod = this.manifest_.periods[currentPeriodIndex]; var reference = mediaState.stream.getSegmentReference(position); if (!reference) { shaka.log.v1(logPrefix, 'segment does not exist:', 'currentPeriod.startTime=' + currentPeriod.startTime, 'position=' + position); return null; } var timeline = this.manifest_.presentationTimeline; var availabilityStart = timeline.getSegmentAvailabilityStart(); var availabilityEnd = timeline.getSegmentAvailabilityEnd(); if ((currentPeriod.startTime + reference.endTime < availabilityStart) || (currentPeriod.startTime + reference.startTime > availabilityEnd)) { shaka.log.v2(logPrefix, 'segment is not available:', 'currentPeriod.startTime=' + currentPeriod.startTime, 'reference.startTime=' + reference.startTime, 'reference.endTime=' + reference.endTime, 'availabilityStart=' + availabilityStart, 'availabilityEnd=' + availabilityEnd); return null; } return reference; }; /** * 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} playheadTime * @param {number} currentPeriodIndex The index of the current Period. * @param {!shaka.media.SegmentReference} reference * @private */ shaka.media.StreamingEngine.prototype.fetchAndAppend_ = function( mediaState, playheadTime, currentPeriodIndex, reference) { var logPrefix = shaka.media.StreamingEngine.logPrefix_(mediaState); var ContentType = shaka.util.ManifestParserUtils.ContentType; var currentPeriod = this.manifest_.periods[currentPeriodIndex]; shaka.log.v1(logPrefix, 'fetchAndAppend_:', 'playheadTime=' + playheadTime, 'currentPeriod.startTime=' + currentPeriod.startTime, 'reference.position=' + reference.position, '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() may be called at any time, so we should // also avoid using mediaState.stream or mediaState.needInitSegment in any // callbacks too. var stream = mediaState.stream; // Compute the append window. var duration = this.manifest_.presentationTimeline.getDuration(); var followingPeriod = this.manifest_.periods[currentPeriodIndex + 1]; var appendWindowStart = currentPeriod.startTime; var appendWindowEnd = followingPeriod ? followingPeriod.startTime : duration; goog.asserts.assert( reference.startTime <= appendWindowEnd, logPrefix + ' segment should start before append window end'); var initSourceBuffer = this.initSourceBuffer_( mediaState, currentPeriodIndex, appendWindowStart, appendWindowEnd); mediaState.performingUpdate = true; // We may set |needInitSegment| to true in switch(), so set it to false here, // since we want it to remain true if switch() is called. mediaState.needInitSegment = false; shaka.log.v2(logPrefix, 'fetching segment'); var fetchSegment = this.fetch_(reference); Promise.all([initSourceBuffer, fetchSegment]).then(function(results) { if (this.destroyed_ || this.fatalError_) return; return this.append_(mediaState, playheadTime, currentPeriod, stream, reference, results[1]); }.bind(this)).then(function() { if (this.destroyed_ || this.fatalError_) return; mediaState.performingUpdate = false; mediaState.recovering = false; if (!mediaState.waitingToClearBuffer) this.playerInterface_.onSegmentAppended(); // Update right away. this.scheduleUpdate_(mediaState, 0); // Subtlety: handleStartup_() calls onStartupComplete() which may call // switch() or seeked(), so we must schedule an update beforehand so // |updateTimer| is set. this.handleStartup_(mediaState, stream); shaka.lo