Source: lib/media/quality_observer.js

/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

goog.provide('shaka.media.QualityObserver');

goog.require('shaka.media.IPlayheadObserver');
goog.require('shaka.log');
goog.require('shaka.util.ArrayUtils');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.ManifestParserUtils');

/**
 * Monitors the quality of content being appended to the source buffers and
 * fires 'qualitychange' events when the media quality at the playhead changes.
 *
 * @implements {shaka.media.IPlayheadObserver}
 * @final
 */
shaka.media.QualityObserver = class extends shaka.util.FakeEventTarget {
  /**
   * Creates a new QualityObserver.
   *
   * @param {!function():!shaka.extern.BufferedInfo} getBufferedInfo
   *   Buffered info is needed to purge QualityChanges that are no
   *   longer relevant.
   */
  constructor(getBufferedInfo) {
    super();

    /**
     * @private {!Map.<string,!shaka.media.QualityObserver.ContentTypeState>}
     */
    this.contentTypeStates_ = new Map();

    /** @private function():!shaka.extern.BufferedInfo */
    this.getBufferedInfo_ = getBufferedInfo;
  }

  /** @override */
  release() {
    this.contentTypeStates_.clear();
    super.release();
  }

  /**
   * Get the ContenTypeState for a contentType, creating a new
   * one if necessary.
   *
   * @param {!string} contentType
   *  The contend type e.g. "video" or "audio".
   * @return {!shaka.media.QualityObserver.ContentTypeState}
   * @private
   */
  getContentTypeState_(contentType) {
    let contentTypeState = this.contentTypeStates_.get(contentType);
    if (!contentTypeState) {
      contentTypeState = {
        qualityChangePositions: [],
        currentQuality: null,
        contentType: contentType,
      };
      this.contentTypeStates_.set(contentType, contentTypeState);
    }
    return contentTypeState;
  }

  /**
   * Adds a QualityChangePosition for the contentType identified by
   * the mediaQuality.contentType.
   *
   * @param {!shaka.extern.MediaQualityInfo} mediaQuality
   * @param {!number} position
   *  Position in seconds of the quality change.
   */
  addMediaQualityChange(mediaQuality, position) {
    const contentTypeState =
      this.getContentTypeState_(mediaQuality.contentType);

    // Remove unneeded QualityChangePosition(s) before adding the new one
    this.purgeQualityChangePositions_(contentTypeState);

    const newChangePosition = {
      mediaQuality: mediaQuality,
      position: position,
    };

    const changePositions = contentTypeState.qualityChangePositions;
    const insertBeforeIndex = changePositions.findIndex(
        (qualityChange) => (qualityChange.position >= position));

    if (insertBeforeIndex >= 0) {
      const duplicatePositions =
        (changePositions[insertBeforeIndex].position == position) ? 1 : 0;
      changePositions.splice(
          insertBeforeIndex, duplicatePositions, newChangePosition);
    } else {
      changePositions.push(newChangePosition);
    }
  }

  /**
   * Determines the media quality at a specific position in the source buffer.
   *
   * @param {!number} position
   *  Position in seconds
   * @param {!shaka.media.QualityObserver.ContentTypeState} contentTypeState
   * @return {?shaka.extern.MediaQualityInfo}
   * @private
   */
  static getMediaQualityAtPosition_(position, contentTypeState) {
    // The qualityChangePositions must be ordered by position ascending
    // Find the last QualityChangePosition prior to the position
    const changePositions = contentTypeState.qualityChangePositions;
    for (let i = changePositions.length - 1; i >= 0; i--) {
      const qualityChange = changePositions[i];
      if (qualityChange.position <= position) {
        return qualityChange.mediaQuality;
      }
    }
    return null;
  }

  /**
   * Determines if two MediaQualityInfo objects are the same or not.
   *
   * @param {?shaka.extern.MediaQualityInfo} mq1
   * @param {?shaka.extern.MediaQualityInfo} mq2
   * @return {boolean}
   * @private
   */
  static mediaQualitiesAreTheSame_(mq1, mq2) {
    if (mq1 === mq2) {
      return true;
    }
    if (!mq1 || !mq2) {
      return false;
    }
    return (mq1.bandwidth == mq2.bandwidth) &&
      (mq1.audioSamplingRate == mq2.audioSamplingRate) &&
      (mq1.codecs == mq2.codecs) &&
      (mq1.contentType == mq2.contentType) &&
      (mq1.frameRate == mq2.frameRate) &&
      (mq1.height == mq2.height) &&
      (mq1.mimeType == mq2.mimeType) &&
      (mq1.channelsCount == mq2.channelsCount) &&
      (mq1.pixelAspectRatio == mq2.pixelAspectRatio) &&
      (mq1.width == mq2.width);
  }

  /** @override */
  poll(positionInSeconds, wasSeeking) {
    for (const contentTypeState of this.contentTypeStates_.values()) {
      const currentQuality = contentTypeState.currentQuality;
      const qualityAtPosition =
        shaka.media.QualityObserver.getMediaQualityAtPosition_(
            positionInSeconds, contentTypeState);

      const differentQualities = qualityAtPosition &&
          !shaka.media.QualityObserver.mediaQualitiesAreTheSame_(
              currentQuality, qualityAtPosition);
      const differentLabel = qualityAtPosition && currentQuality &&
          qualityAtPosition.label && currentQuality.label &&
          currentQuality.label !== qualityAtPosition.label;
      const differentLanguage = qualityAtPosition && currentQuality &&
          qualityAtPosition.language && currentQuality.language &&
          currentQuality.language !== qualityAtPosition.language;
      const differentRoles = qualityAtPosition && currentQuality &&
          qualityAtPosition.roles && currentQuality.roles &&
          !shaka.util.ArrayUtils.equal(currentQuality.roles,
              qualityAtPosition.roles);

      if (differentLabel || differentLanguage || differentRoles) {
        if (this.positionIsBuffered_(
            positionInSeconds, qualityAtPosition.contentType)) {
          contentTypeState.currentQuality = qualityAtPosition;

          const event = new shaka.util.FakeEvent('audiotrackchange', new Map([
            ['quality', qualityAtPosition],
            ['position', positionInSeconds],
          ]));
          this.dispatchEvent(event);
        }
      }

      if (differentQualities) {
        if (this.positionIsBuffered_(
            positionInSeconds, qualityAtPosition.contentType)) {
          contentTypeState.currentQuality = qualityAtPosition;

          shaka.log.debug('Media quality changed at position ' +
            positionInSeconds + ' ' + JSON.stringify(qualityAtPosition));

          const event = new shaka.util.FakeEvent('qualitychange', new Map([
            ['quality', qualityAtPosition],
            ['position', positionInSeconds],
          ]));
          this.dispatchEvent(event);
        }
      }
    }
  }

  /**
   * Determine if a position is buffered for a given content type.
   *
   * @param {!number} position
   * @param {!string} contentType
   * @private
   */
  positionIsBuffered_(position, contentType) {
    const bufferedInfo = this.getBufferedInfo_();
    const bufferedRanges = bufferedInfo[contentType];
    if (bufferedRanges && bufferedRanges.length > 0) {
      const bufferStart = bufferedRanges[0].start;
      const bufferEnd = bufferedRanges[bufferedRanges.length - 1].end;
      if (position >= bufferStart && position < bufferEnd) {
        return true;
      }
    }
    return false;
  }

  /**
   * Removes the QualityChangePosition(s) that are not relevant to the buffered
   * content of the specified contentType. Note that this function is
   * invoked just before adding the quality change info associated with
   * the next media segment to be appended.
   *
   * @param {!shaka.media.QualityObserver.ContentTypeState} contentTypeState
   * @private
   */
  purgeQualityChangePositions_(contentTypeState) {
    const bufferedInfo = this.getBufferedInfo_();
    const bufferedRanges = bufferedInfo[contentTypeState.contentType];

    if (bufferedRanges && bufferedRanges.length > 0) {
      const bufferStart = bufferedRanges[0].start;
      const bufferEnd = bufferedRanges[bufferedRanges.length - 1].end;
      const oldChangePositions = contentTypeState.qualityChangePositions;
      contentTypeState.qualityChangePositions =
        oldChangePositions.filter(
            (qualityChange, index) => {
              // Remove all but last quality change before bufferStart.
              if ((qualityChange.position <= bufferStart) &&
                (index + 1 < oldChangePositions.length) &&
                (oldChangePositions[index + 1].position <= bufferStart)) {
                return false;
              }
              // Remove all quality changes after bufferEnd.
              if (qualityChange.position >= bufferEnd) {
                return false;
              }
              return true;
            });
    } else {
      // Nothing is buffered; so remove all quality changes.
      contentTypeState.qualityChangePositions = [];
    }
  }

  /**
   * Create a MediaQualityInfo object from a stream object.
   *
   * @param {!shaka.extern.Stream} stream
   * @return {!shaka.extern.MediaQualityInfo}
   */
  static createQualityInfo(stream) {
    const basicQuality = {
      bandwidth: stream.bandwidth || 0,
      audioSamplingRate: null,
      codecs: stream.codecs,
      contentType: stream.type,
      frameRate: null,
      height: null,
      mimeType: stream.mimeType,
      channelsCount: null,
      pixelAspectRatio: null,
      width: null,
      label: null,
      roles: stream.roles,
      language: null,
    };
    if (stream.type == shaka.util.ManifestParserUtils.ContentType.VIDEO) {
      basicQuality.frameRate = stream.frameRate || null;
      basicQuality.height = stream.height || null;
      basicQuality.pixelAspectRatio = stream.pixelAspectRatio || null;
      basicQuality.width = stream.width || null;
    }
    if (stream.type == shaka.util.ManifestParserUtils.ContentType.AUDIO) {
      basicQuality.audioSamplingRate = stream.audioSamplingRate;
      basicQuality.channelsCount = stream.channelsCount;
      basicQuality.label = stream.label || null;
      basicQuality.language = stream.language;
    }
    return basicQuality;
  }
};

/**
  * @typedef {{
  *   mediaQuality: !shaka.extern.MediaQualityInfo,
  *   position: !number
  * }}
  *
  * @description
  * Identifies the position of a media quality change in the
  * source buffer.
  *
  * @property {shaka.extern.MediaQualityInfo} !mediaQuality
  *   The new media quality for content after position in the source buffer.
  * @property {number} !position
  *   A position in seconds in the source buffer
  */
shaka.media.QualityObserver.QualityChangePosition;

/**
  * @typedef {{
  *  qualityChangePositions:
  *   !Array.<shaka.media.QualityObserver.QualityChangePosition>,
  *  currentQuality: ?shaka.extern.MediaQualityInfo,
  *  contentType: !string
  * }}
  *
  * @description
  * Contains media quality information for a specific content type
  * e.g. video or audio.
  *
  * @property {!Array.<shaka.media.QualityObserver.QualityChangePosition>}
  * qualityChangePositions
  *   Quality changes ordered by position ascending.
  * @property {?shaka.media.MediaQualityInfo} currentMediaQuality
  *   The media quality at the playhead position.
  * @property {string} contentType
  *   The contentType e.g. 'video' or 'audio'
  */
shaka.media.QualityObserver.ContentTypeState;