Source: lib/abr/simple_ll_abr_manager.js

goog.provide('shaka.abr.SimpleLLAbrManager');

goog.require('goog.asserts');
goog.require('shaka.abr.SlidingPercentileBandwidthEstimator');
goog.require('shaka.log');
goog.require('shaka.util.StreamUtils');
goog.require('shaka.util.Timer');
goog.require('shaka.media.StreamingEngine');


/**
 * @summary
 * <p>
 * This defines the default Low Latency ABR manager for the Player
 * </p>
 *
 * @implements {shaka.extern.AbrManager}
 * @export
 */
shaka.abr.SimpleLLAbrManager = class {
  /**
   *
   */
  constructor() {
    /** @private {?shaka.abr.SimpleLLAbrManager.PlayerInterface} */
    this.playerInterface_ = null;

    /** @private {?shaka.extern.AbrManager.SwitchCallback} */
    this.switch_ = null;

    /** @private {boolean} */
    this.enabled_ = false;

    /** @private {shaka.abr.SlidingPercentileBandwidthEstimator} */
    this.bandwidthEstimator_ =
      new shaka.abr.SlidingPercentileBandwidthEstimator();

    // Some browsers implement the Network Information API, which allows
    // retrieving information about a user's network connection. We listen
    // to the change event to be able to make quick changes in case the type
    // of connectivity changes.
    if (navigator.connection) {
      navigator.connection.addEventListener('change', () => {
        if (this.config_.useNetworkInformation) {
          this.bandwidthEstimator_ =
            new shaka.abr.SlidingPercentileBandwidthEstimator();
          const chosenVariant = this.chooseVariant();
          if (chosenVariant) {
            this.switch_(chosenVariant);
          }
        }
      });
    }

    /**
     * A filtered list of Variants to choose from.
     * @private {!Array.<!shaka.extern.Variant>}
     */
    this.variants_ = [];

    /** @private {number} */
    this.playbackRate_ = 1;

    /** @private {boolean} */
    this.startupComplete_ = false;

    /**
     * The last wall-clock time, in milliseconds, when streams were chosen.
     *
     * @private {?number}
     */
    this.lastTimeChosenMs_ = null;

    /** @private {?shaka.extern.AbrConfiguration} */
    this.config_ = null;

    /** @private {number} */
    this.currentBitrate_ = 0;

    /** @private {number} */
    this.stallCount_ = 0;

    /** @private {number} */
    this.resetStallCountDelay_ = 30;

    /** @private {number} */
    this.increaseVideoBitrateDelay_ = 10;

    /** @private {number} */
    this.consecutiveFailedIncreaseVideoBitrateCount_ = 0;

    /** @private {number} */
    this.bufferingTimeToDecreaseBitrate_ = 0.5;

    /** @private {boolean} */
    this.isBuffering_ = false;

    /** @private {boolean} */
    this.isPreviousSwitchIncrease_ = false;

    /** @private {boolean} */
    this.isSwitchIncrease_ = true;

    /** @private {shaka.util.Timer} */
    this.resetStallCountTimer_ = new shaka.util.Timer(() => {
      this.resetStallCount_();
    });

    /** @private {shaka.util.Timer} */
    this.increaseBitrateTimer_ = new shaka.util.Timer(() => {
      this.increaseVideoBitrate_();
    });

    /** @private {shaka.util.Timer} */
    this.decreaseBitrateTimer_ = new shaka.util.Timer(() => {
      this.decreaseVideoBitrate_();
    });

    /** @private {shaka.util.Timer} */
    this.suggestStreamTimer_ = new shaka.util.Timer(() => {
      this.scheduleSuggestStream_();
    });

    /** @private {!Array.<string>} */
    this.processedUris_ = [];

    /** @private {!Set.<string>} */
    this.processedUriSet_ = new Set();

    /** @private {number} */
    this.maxProcessedUriCount_ = 100;

    /** @private {?shaka.extern.Variant} */
    this.currentVariant_ = null;
  }


  /**
   * @param {?shaka.abr.SimpleLLAbrManager.PlayerInterface} playerInterface
   */
  setPlayerInterface(playerInterface) {
    this.playerInterface_ = playerInterface;
  }


  /**
   * @override
   * @export
   */
  stop() {
    this.switch_ = null;
    this.enabled_ = false;
    this.variants_ = [];
    this.lastTimeChosenMs_ = null;
    this.playbackRate_ = 1;
  }


  /**
   * @override
   * @export
   */
  init(switchCallback) {
    this.switch_ = switchCallback;
  }


  /**
   * @override
   * @export
   */
  chooseVariant() {
    const AbrManager = shaka.abr.SimpleLLAbrManager;

    // Get sorted Variants.
    let sortedVariants = AbrManager.filterAndSortVariants_(
        this.config_.restrictions, this.variants_);

    if (this.variants_.length && !sortedVariants.length) {
      shaka.log.warning('No variants met the ABR restrictions. ' +
                        'Choosing a variant by lowest bandwidth.');
      sortedVariants = AbrManager.filterAndSortVariants_(
          /* restrictions= */ null, this.variants_);
      sortedVariants = [sortedVariants[0]];
    }

    let chosen = sortedVariants.length > 0 ?
        sortedVariants[sortedVariants.length - 1] : null;
    if (this.bandwidthEstimator_.hasGoodEstimate()) {
      chosen = this.chooseVariantByBandwidth_(sortedVariants);
    } else {
      if (this.lastTimeChosenMs_) {
        if (this.isSwitchIncrease_) {
          chosen = this.chooseNextHigherBandwidthVariant_(sortedVariants);
        } else {
          chosen = this.chooseNextLowerBandwidthVariant_(sortedVariants);
        }
        if (chosen) {
          this.currentBitrate_ = chosen.bandwidth;
          shaka.log.info(this.isSwitchIncrease_ ? 'Increase bandwidth to' :
          'Decrease bandwidth to', chosen.bandwidth);
        }
        this.isPreviousSwitchIncrease_ = this.isSwitchIncrease_;
        this.scheduleIncreaseVideoBitrate_();
      }
    }

    this.lastTimeChosenMs_ = Date.now();
    return chosen;
  }

  /**
   * @param {!Array.<shaka.extern.Variant>} sortedVariants
   * @private
   */
  chooseVariantByBandwidth_(sortedVariants) {
    const defaultBandwidthEstimate = this.getDefaultBandwidth_();
    const currentBandwidth = this.bandwidthEstimator_.getBandwidthEstimate(
        defaultBandwidthEstimate);

    // Start by assuming that we will use the first Stream.
    let chosen = sortedVariants[0] || null;

    for (let i = 0; i < sortedVariants.length; i++) {
      const item = sortedVariants[i];
      const next = sortedVariants[i + 1];
      const playbackRate =
          !isNaN(this.playbackRate_) ? Math.abs(this.playbackRate_) : 1;
      const itemBandwidth = playbackRate * item.bandwidth;
      const minBandwidth =
          itemBandwidth / this.config_.bandwidthDowngradeTarget;
      const nextBandwidth =
          playbackRate * (next || {bandwidth: Infinity}).bandwidth;
      const maxBandwidth =
          nextBandwidth / this.config_.bandwidthUpgradeTarget;
      shaka.log.v2('Bandwidth ranges:',
          (itemBandwidth / 1e6).toFixed(3),
          (minBandwidth / 1e6).toFixed(3),
          (maxBandwidth / 1e6).toFixed(3));

      if (currentBandwidth >= minBandwidth &&
          currentBandwidth <= maxBandwidth) {
        chosen = item;
      }
    }
    return chosen;
  }

  /**
   * Find the first variant that greater than current bitrate
   *
   * @param {!Array.<shaka.extern.Variant>} sortedVariants
   * @private
   */
  chooseNextHigherBandwidthVariant_(sortedVariants) {
    let chosen = sortedVariants.length > 0 ?
        sortedVariants[sortedVariants.length - 1] : null;
    for (let i = 0; i < sortedVariants.length; i++) {
      if (this.isPreviousSwitchIncrease_) {
        this.consecutiveFailedIncreaseVideoBitrateCount_ = 0;
      }
      const item = sortedVariants[i];
      if (item.bandwidth > this.currentBitrate_) {
        chosen = item;
        break;
      }
    }
    return chosen;
  }

  /**
   * Find the first variant that smaller than current bitrate
   *
   * @param {!Array.<shaka.extern.Variant>} sortedVariants
   * @private
   */
  chooseNextLowerBandwidthVariant_(sortedVariants) {
    let chosen = sortedVariants.length > 0 ? sortedVariants[0] : null;
    for (let i = sortedVariants.length - 1; i >= 0; i--) {
      if (this.isPreviousSwitchIncrease_) {
        this.consecutiveFailedIncreaseVideoBitrateCount_++;
      } else {
        this.consecutiveFailedIncreaseVideoBitrateCount_ = 0;
      }
      const item = sortedVariants[i];
      if (item.bandwidth < this.currentBitrate_) {
        chosen = item;
        break;
      }
    }
    return chosen;
  }


  /**
   * @override
   * @export
   */
  enable() {
    this.enabled_ = true;
  }


  /**
   * @override
   * @export
   */
  disable() {
    this.enabled_ = false;
  }


  /**
   * @override
   * @export
   */
  segmentDownloaded(deltaTimeMs, numBytes, uri) {
    shaka.log.v2('Segment downloaded:',
        'deltaTimeMs=' + deltaTimeMs,
        'numBytes=' + numBytes,
        'lastTimeChosenMs=' + this.lastTimeChosenMs_,
        'enabled=' + this.enabled_,
        'uri', uri);
    goog.asserts.assert(deltaTimeMs >= 0, 'expected a non-negative duration');
  }


  /**
   * @override
   * @export
   */
  segmentDownloadCompleted(deltaTimeMs, numBytes, uris, isFullSpeed) {
    let processed = false;
    for (let i = 0; i < uris.length; i++) {
      const uri = uris[i];
      if (this.processedUriSet_.has(uri)) {
        processed = true;
        break;
      }
    }

    if (isFullSpeed && !processed) {
      this.bandwidthEstimator_.sample(deltaTimeMs, numBytes);
      if (this.lastTimeChosenMs_ != null && this.enabled_ &&
        this.bandwidthEstimator_.hasGoodEstimate()) {
        this.scheduleSuggestStream_();
      }
    }

    for (let i = 0; i < uris.length; i++) {
      const uri = uris[i];
      this.processedUris_.push(uri);
      this.processedUriSet_.add(uri);
      if (this.processedUris_.length > this.maxProcessedUriCount_) {
        this.processedUriSet_.delete(this.processedUris_.shift());
      }
    }
  }


  /**
   * @override
   * @export
   */
  getBandwidthEstimate() {
    return this.bandwidthEstimator_.getBandwidthEstimate(
        this.config_.defaultBandwidthEstimate);
  }


  /**
   * @override
   * @export
   */
  setVariants(variants) {
    this.variants_ = variants;
  }


  /**
   * @override
   * @export
   */
  playbackRateChanged(rate) {
    this.playbackRate_ = rate;
  }


  /**
   * @override
   * @export
   */
  configure(config) {
    this.config_ = config;
  }


  /**
   * Calls switch_() with the variant chosen by chooseVariant().
   *
   * @private
   */
  suggestStreams_() {
    shaka.log.v2('Suggesting Streams...');
    goog.asserts.assert(this.lastTimeChosenMs_ != null,
        'lastTimeChosenMs_ should not be null');

    if (!this.startupComplete_) {
      // Check if we've got enough data yet.
      if (!this.bandwidthEstimator_.hasGoodEstimate()) {
        shaka.log.v2('Still waiting for a good estimate...');
        return;
      }
      this.startupComplete_ = true;
    } else {
      // Check if we've left the switch interval.
      const now = Date.now();
      const delta = now - this.lastTimeChosenMs_;
      if (delta < this.config_.switchInterval * 1000) {
        shaka.log.v2('Still within switch interval...');
        return;
      }
    }

    const chosenVariant = this.chooseVariant();
    const defaultBandwidthEstimate = this.getDefaultBandwidth_();
    const bandwidthEstimate = this.bandwidthEstimator_.getBandwidthEstimate(
        defaultBandwidthEstimate);
    const currentBandwidthKbps = Math.round(bandwidthEstimate / 1000.0);

    if (chosenVariant && chosenVariant != this.currentVariant_) {
      shaka.log.debug(
          'Calling switch_(), bandwidth=' + currentBandwidthKbps + ' kbps');
      // If any of these chosen streams are already chosen, Player will filter
      // them out before passing the choices on to StreamingEngine.
      this.switch_(chosenVariant);
      this.currentVariant_ = chosenVariant;
    }
  }


  /**
   * @private
   */
  getDefaultBandwidth_() {
    let defaultBandwidthEstimate = this.config_.defaultBandwidthEstimate;
    if (navigator.connection && navigator.connection.downlink &&
        this.config_.useNetworkInformation) {
      defaultBandwidthEstimate = navigator.connection.downlink * 1e6;
    }
    return defaultBandwidthEstimate;
  }


  /**
   * @override
   * @export
   */
  onBuffering() {
    if (this.bandwidthEstimator_.hasGoodEstimate()) {
      return;
    }

    this.isBuffering_ = true;
    this.stallCount_++;
    this.scheduleResetStallCount_();
    this.scheduleIncreaseVideoBitrate_();
    this.decreaseBitrateTimer_.tickAfter(this.bufferingTimeToDecreaseBitrate_);

    if (this.stallCount_ >= this.config_.stallCountToDowngrade) {
      this.decreaseVideoBitrate_();
    }
  }

  /**
   * @override
   * @export
   */
  onBufferingEnd() {
    this.isBuffering_ = false;
    this.decreaseBitrateTimer_.stop();
  }


  /**
   * @private
   */
  resetStallCount_() {
    this.stallCount_ = 0;
  }


  /**
   * @private
   */
  scheduleResetStallCount_() {
    this.resetStallCountTimer_.stop();
    this.resetStallCountTimer_.tickAfter(this.resetStallCountDelay_);
  }


  /**
   * @private
   */
  scheduleIncreaseVideoBitrate_() {
    this.increaseBitrateTimer_.stop();
    this.increaseBitrateTimer_.tickAfter(this.increaseVideoBitrateDelay_ <<
      this.consecutiveFailedIncreaseVideoBitrateCount_);
  }

  /**
   * @private
   */
  scheduleSuggestStream_() {
    if (this.isBuffering_) {
      this.suggestStreamTimer_.stop();
      this.suggestStreamTimer_.tickAfter(0.1);
      return;
    }
    this.suggestStreams_();
  }


  /**
   * @private
   */
  increaseVideoBitrate_() {
    if (!this.playerInterface_) {
      return;
    }

    this.isSwitchIncrease_ = true;
    if (this.enabled_) {
      const serviceDescription = this.playerInterface_.getServiceDescription();
      let latency;
      if (serviceDescription) {
        latency = this.playerInterface_.getPresentationLatencyInfo().latency;
      }
      if (latency && latency > serviceDescription.latency.max) {
        this.scheduleIncreaseVideoBitrate_();
      } else {
        this.scheduleSuggestStream_();
      }
    }
  }


  /**
   * @private
   */
  decreaseVideoBitrate_() {
    this.isSwitchIncrease_ = false;
    if (this.enabled_) {
      this.scheduleSuggestStream_();
    }
    this.resetStallCount_();
  }


  /**
   * @return {number}
   */
  getIncreaseVideoBitrateDelay() {
    return this.increaseVideoBitrateDelay_;
  }


  /**
   * @return {?shaka.extern.Variant}
   */
  getCurrentVariant() {
    return this.currentVariant_;
  }


  /**
   * @param {number} index
   * @return {?string}
   */
  getProcessedUri(index) {
    if (index < this.processedUris_.length) {
      return this.processedUris_[index];
    }
    return null;
  }


  /**
   * @return {number}
   */
  getProcessedUriCount() {
    return this.processedUris_.length;
  }


  /**
   * @param {?shaka.extern.Restrictions} restrictions
   * @param {!Array.<shaka.extern.Variant>} variants
   * @return {!Array.<shaka.extern.Variant>} variants filtered according to
   *   |restrictions| and sorted in ascending order of bandwidth.
   * @private
   */
  static filterAndSortVariants_(restrictions, variants) {
    if (restrictions) {
      variants = variants.filter((variant) => {
        // This was already checked in another scope, but the compiler doesn't
        // seem to understand that.
        goog.asserts.assert(restrictions, 'Restrictions should exist!');

        return shaka.util.StreamUtils.meetsRestrictions(
            variant, restrictions,
            /* maxHwRes= */ {width: Infinity, height: Infinity});
      });
    }

    return variants.sort((v1, v2) => {
      return v1.bandwidth - v2.bandwidth;
    });
  }
};

/**
 * @typedef {{
 *   getPresentationLatencyInfo: (function():
 *   shaka.media.StreamingEngine.PresentationLatencyInfo|null),
 *   getServiceDescription:
 *   (function():shaka.extern.ServiceDescription|undefined)
 * }}
 *
 * @export
 */
shaka.abr.SimpleLLAbrManager.PlayerInterface;