Source: lib/media/buffering_observer.js

/**
 * @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.BufferingObserver');

goog.require('shaka.media.IPlayheadObserver');


/**
 * The buffering observer watches how much content the video element has
 * buffered and raises events when the state changes (enough => not enough or
 * vice versa).
 *
 * The one listening to the events should take action to avoid running out of
 * content.
 *
 * @implements {shaka.media.IPlayheadObserver}
 * @final
 */
shaka.media.BufferingObserver = class {
  /**
   * @param {number} thresholdWhenStarving
   *    The threshold for how many seconds worth of content must be buffered
   *    ahead of the playhead position to leave a STARVING state.
   * @param {shaka.media.BufferingObserver.State} initialState
   *    The state that the observer starts in. We allow this so that it is
   *    easier to test, rather than having to "force" the observer into a
   *    particular state through simulation in the test.
   * @param {function(number):number} getSecondsBufferedAfter
   *    Get the number of seconds after the given time (in seconds) that have
   *    buffered.
   * @param {function():boolean} isBufferedToEnd
   *    When we call |poll|, we need to know if we are buffered to the end of
   *    the presentation. This method should return |true| when we have
   *    buffered to the end of the current presentation. In terms of live
   *    content, this will return |true| when we are buffered to the live edge.
   */
  constructor(thresholdWhenStarving,
              initialState,
              getSecondsBufferedAfter,
              isBufferedToEnd) {
    /**
     * The state (SATISFIED vs STARVING) at last check.  This value will always
     * be "old", and we will compare it to what we evaluate in the "present" to
     * see when the state has changed.
     *
     * @private {shaka.media.BufferingObserver.State}
     */
    this.previousState_ = initialState;

    /**
     * The minimum amount of content that must be buffered ahead of the playhead
     * to avoid a transition from SATISFIED to STARVING, i.e. to remain in
     * SATISFIED.  This will be used when we the previous state is SATISFIED.
     *
     * Combined with |thresholdWhenStarving_|, this adds hysteresis to the
     * state machine to avoid frequent switches around a single threshold.
     * https://bit.ly/2QLQNtG
     *
     * @private {number}
     */
    this.thresholdWhenSatisfied_ = 0.5;

    /**
     * The minimum amount of content that must be buffered ahead of the playhead
     * to transition from STARVING to SATISFIED.  This will be used when the
     * previous state is STARVING.
     *
     * Combined with |thresholdWhenSatisfied_|, this adds hysteresis to the
     * state machine to avoid frequent switches around a single threshold.
     * https://bit.ly/2QLQNtG
     *
     * @private {number}
     */
    this.thresholdWhenStarving_ = thresholdWhenStarving;

    /**
     * When we call |poll|, we need to know if we are buffered to the end of
     * the presentation. This method should return |true| when we have
     * buffered to the end of the current presentation. In terms of live
     * content, this will return |true| when we are buffered to the live edge.
     *
     * Checking if we are buffered to the end of the presentation relies on a
     * number of factors. Which factors can even depend on what it loaded. To
     * avoid having all those factors here, we use an external callback so that
     * this implementation can be move flexible and easier to test.
     *
     * @private {function():boolean}
     */
    this.isBufferedToEnd_ = isBufferedToEnd;

    /**
     * A callback to get the number of seconds of buffered content that comes
     * after the given presentation time (in seconds).
     *
     * @private {function(number):number}
     */
    this.getSecondsBufferedAfter_ = getSecondsBufferedAfter;

    /** @private {function()} */
    this.onStarving_ = () => {};

    /** @private {function()} */
    this.onSatisfied_ = () => {};

    /**
     * A series of rules that we will use to determine what callback to use
     * when the playhead moves.
     *
     * @private {!Array.<shaka.media.BufferingObserver.Rule_>}
     */
    this.rules_ = [
      {
        was: shaka.media.BufferingObserver.State.STARVING,
        is: shaka.media.BufferingObserver.State.SATISFIED,
        doThis: () => this.onSatisfied_(),
      },
      {
        was: shaka.media.BufferingObserver.State.SATISFIED,
        is: shaka.media.BufferingObserver.State.STARVING,
        doThis: () => this.onStarving_(),
      },
    ];

    // If the thresholds are inverted, it could be possible that we miss a
    // transition from SATISFIED to STARVING in some cases.  This could have
    // serious consequences for playback, preventing us from entering a
    // buffering state, and causing interruptions in playback with no cause
    // obvious to the user.
    if (this.thresholdWhenSatisfied_ >= this.thresholdWhenStarving_) {
      // If this happens, warn the user and reduce |thresholdWhenSatisfied_| to
      // restore the correct mathematical relationship between the two.  The
      // behavior may still be poor, since the difference between the two
      // thresholds will be small and the hysteresis will be less effective.
      shaka.log.alwaysWarn(
          'Rebuffering threshold is set too low!  This could cause poor ' +
          'buffering behavior during playback!');
      this.thresholdWhenSatisfied_ = this.thresholdWhenStarving_ / 2;
    }
  }

  /** @override */
  release() {
    // Clear the callbacks so that we don't hold references to parts of the
    // listeners.
    this.onStarving_ = () => {};
    this.onSatisfied_ = () => {};
  }

  /** @override */
  poll(positionInSeconds, wasSeeking) {
    const State = shaka.media.BufferingObserver.State;
    // Our threshold for how much we need before we declare ourselves as
    // starving is based on whether or not we were just starving. If we
    // were just starving, we are more likely to starve again, so we require
    // more content to be buffered than if we were not just starving.
    const threshold = this.previousState_ == State.SATISFIED ?
                      this.thresholdWhenSatisfied_ :
                      this.thresholdWhenStarving_;

    // Check how far ahead of |currentTime| we have buffered. The most we have,
    // the better off we are.
    const amountBuffered = this.getSecondsBufferedAfter_(positionInSeconds);

    /** @type {boolean} */
    const isBufferedToEnd = this.isBufferedToEnd_();

    const currentState = (isBufferedToEnd || amountBuffered >= threshold) ?
                         (State.SATISFIED) :
                         (State.STARVING);

    // Execute all the rules that apply to the current state.
    for (const rule of this.rules_) {
      if (this.previousState_ == rule.was && currentState == rule.is) {
        rule.doThis();
      }
    }

    // Store the current state so that we can detect a change in state next
    // time |applyNewPlayheadPosition| is called.
    this.previousState_ = currentState;
  }

  /**
   * Set the listeners. This will override any previous calls to |setListeners|.
   *
   * @param {function()} onStarving
   *    The callback for when we change from "satisfied" to "starving".
   * @param {function()} onSatisfied
   *    The callback for when we change from "starving" to "satisfied".
   */
  setListeners(onStarving, onSatisfied) {
    this.onStarving_ = onStarving;
    this.onSatisfied_ = onSatisfied;
  }
};

/**
 * Rather than using booleans to communicate what state we are in, we have this
 * enum.
 *
 * @enum {number}
 */
shaka.media.BufferingObserver.State = {
  STARVING: 0,
  SATISFIED: 1,
};

/**
 * @typedef {{
 *    was: shaka.media.BufferingObserver.State,
 *    is: shaka.media.BufferingObserver.State,
 *    doThis: function()
 * }}
 */
shaka.media.BufferingObserver.Rule_;