Source: lib/media/presentation_timeline.js

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

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

goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.media.SegmentReference');


/**
 * PresentationTimeline.
 * @export
 */
shaka.media.PresentationTimeline = class {
  /**
   * @param {?number} presentationStartTime The wall-clock time, in seconds,
   *   when the presentation started or will start. Only required for live.
   * @param {number} presentationDelay The delay to give the presentation, in
   *   seconds.  Only required for live.
   * @param {boolean=} autoCorrectDrift Whether to account for drift when
   *   determining the availability window.
   *
   * @see {shaka.extern.Manifest}
   * @see {@tutorial architecture}
   */
  constructor(presentationStartTime, presentationDelay,
      autoCorrectDrift = true) {
    /** @private {?number} */
    this.presentationStartTime_ = presentationStartTime;

    /** @private {number} */
    this.presentationDelay_ = presentationDelay;

    /** @private {number} */
    this.duration_ = Infinity;

    /** @private {number} */
    this.segmentAvailabilityDuration_ = Infinity;

    /**
     * The maximum segment duration (in seconds).  Can be based on explicitly-
     * known segments or on signalling in the manifest.
     *
     * @private {number}
     */
    this.maxSegmentDuration_ = 1;

    /**
     * The minimum segment start time (in seconds, in the presentation timeline)
     * for segments we explicitly know about.
     *
     * This is null if we have no explicit descriptions of segments, such as in
     * DASH when using SegmentTemplate w/ duration.
     *
     * @private {?number}
     */
    this.minSegmentStartTime_ = null;

    /**
     * The maximum segment end time (in seconds, in the presentation timeline)
     * for segments we explicitly know about.
     *
     * This is null if we have no explicit descriptions of segments, such as in
     * DASH when using SegmentTemplate w/ duration.  When this is non-null, the
     * presentation start time is calculated from the segment end times.
     *
     * @private {?number}
     */
    this.maxSegmentEndTime_ = null;

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

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

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

    /** @private {boolean} */
    this.autoCorrectDrift_ = autoCorrectDrift;
  }


  /**
   * @return {number} The presentation's duration in seconds.
   *   Infinity indicates that the presentation continues indefinitely.
   * @export
   */
  getDuration() {
    return this.duration_;
  }


  /**
   * @return {number} The presentation's max segment duration in seconds.
   * @export
   */
  getMaxSegmentDuration() {
    return this.maxSegmentDuration_;
  }


  /**
   * Sets the presentation's duration.
   *
   * @param {number} duration The presentation's duration in seconds.
   *   Infinity indicates that the presentation continues indefinitely.
   * @export
   */
  setDuration(duration) {
    goog.asserts.assert(duration > 0, 'duration must be > 0');
    this.duration_ = duration;
  }


  /**
   * @return {?number} The presentation's start time in seconds.
   * @export
   */
  getPresentationStartTime() {
    return this.presentationStartTime_;
  }


  /**
   * Sets the clock offset, which is the difference between the client's clock
   * and the server's clock, in milliseconds (i.e., serverTime = Date.now() +
   * clockOffset).
   *
   * @param {number} offset The clock offset, in ms.
   * @export
   */
  setClockOffset(offset) {
    this.clockOffset_ = offset;
  }


  /**
   * Sets the presentation's static flag.
   *
   * @param {boolean} isStatic If true, the presentation is static, meaning all
   *   segments are available at once.
   * @export
   */
  setStatic(isStatic) {
    // NOTE: the argument name is not "static" because that's a keyword in ES6
    this.static_ = isStatic;
  }


  /**
   * Sets the presentation's segment availability duration. The segment
   * availability duration should only be set for live.
   *
   * @param {number} segmentAvailabilityDuration The presentation's new segment
   *   availability duration in seconds.
   * @export
   */
  setSegmentAvailabilityDuration(segmentAvailabilityDuration) {
    goog.asserts.assert(segmentAvailabilityDuration >= 0,
        'segmentAvailabilityDuration must be >= 0');
    this.segmentAvailabilityDuration_ = segmentAvailabilityDuration;
  }


  /**
   * Sets the presentation delay in seconds.
   *
   * @param {number} delay
   * @export
   */
  setDelay(delay) {
    // NOTE: This is no longer used internally, but is exported.
    // So we cannot remove it without deprecating it and waiting one release
    // cycle, or else we risk breaking custom manifest parsers.
    goog.asserts.assert(delay >= 0, 'delay must be >= 0');
    this.presentationDelay_ = delay;
  }


  /**
   * Gets the presentation delay in seconds.
   * @return {number}
   * @export
   */
  getDelay() {
    return this.presentationDelay_;
  }


  /**
   * Gives PresentationTimeline a Stream's segments so it can size and position
   * the segment availability window, and account for missing segment
   * information.  This function should be called once for each Stream (no more,
   * no less).
   *
   * @param {!Array.<!shaka.media.SegmentReference>} references
   * @export
   */
  notifySegments(references) {
    if (references.length == 0) {
      return;
    }

    const firstReferenceStartTime = references[0].startTime;
    const lastReferenceEndTime = references[references.length - 1].endTime;

    this.notifyMinSegmentStartTime(firstReferenceStartTime);

    this.maxSegmentDuration_ = references.reduce(
        (max, r) => { return Math.max(max, r.endTime - r.startTime); },
        this.maxSegmentDuration_);

    this.maxSegmentEndTime_ =
        Math.max(this.maxSegmentEndTime_, lastReferenceEndTime);

    if (this.presentationStartTime_ != null && this.autoCorrectDrift_) {
      // Since we have explicit segment end times, calculate a presentation
      // start based on them.  This start time accounts for drift.
      // Date.now() is in milliseconds, from which we compute "now" in seconds.
      const now = (Date.now() + this.clockOffset_) / 1000.0;
      this.presentationStartTime_ =
          now - this.maxSegmentEndTime_ - this.maxSegmentDuration_;
    }

    shaka.log.v1('notifySegments:',
        'maxSegmentDuration=' + this.maxSegmentDuration_);
  }


  /**
   * Gives PresentationTimeline a Stream's minimum segment start time.
   *
   * @param {number} startTime
   * @export
   */
  notifyMinSegmentStartTime(
      startTime) {
    if (this.minSegmentStartTime_ == null) {
      // No data yet, and Math.min(null, startTime) is always 0.  So just store
      // startTime.
      this.minSegmentStartTime_ = startTime;
    } else {
      this.minSegmentStartTime_ =
          Math.min(this.minSegmentStartTime_, startTime);
    }
  }


  /**
   * Gives PresentationTimeline a Stream's maximum segment duration so it can
   * size and position the segment availability window.  This function should be
   * called once for each Stream (no more, no less), but does not have to be
   * called if notifySegments() is called instead for a particular stream.
   *
   * @param {number} maxSegmentDuration The maximum segment duration for a
   *   particular stream.
   * @export
   */
  notifyMaxSegmentDuration(maxSegmentDuration) {
    this.maxSegmentDuration_ = Math.max(
        this.maxSegmentDuration_, maxSegmentDuration);

    shaka.log.v1('notifyNewSegmentDuration:',
        'maxSegmentDuration=' + this.maxSegmentDuration_);
  }


  /**
   * Offsets the segment times by the given amount.
   *
   * @param {number} offset The number of seconds to offset by.  A positive
   *   number adjusts the segment times forward.
   * @export
   */
  offset(offset) {
    if (this.minSegmentStartTime_ != null) {
      this.minSegmentStartTime_ += offset;
    }
    if (this.maxSegmentEndTime_ != null) {
      this.maxSegmentEndTime_ += offset;
    }
  }


  /**
   * @return {boolean} True if the presentation is live; otherwise, return
   *   false.
   * @export
   */
  isLive() {
    return this.duration_ == Infinity &&
           !this.static_;
  }


  /**
   * @return {boolean} True if the presentation is in progress (meaning not
   *   live, but also not completely available); otherwise, return false.
   * @export
   */
  isInProgress() {
    return this.duration_ != Infinity &&
           !this.static_;
  }


  /**
   * Gets the presentation's current segment availability start time.  Segments
   * ending at or before this time should be assumed to be unavailable.
   *
   * @return {number} The current segment availability start time, in seconds,
   *   relative to the start of the presentation.
   * @export
   */
  getSegmentAvailabilityStart() {
    goog.asserts.assert(this.segmentAvailabilityDuration_ >= 0,
        'The availability duration should be positive');

    const end = this.getSegmentAvailabilityEnd();
    const start = end - this.segmentAvailabilityDuration_;
    return Math.max(this.userSeekStart_, start);
  }


  /**
   * Sets the start time of the user-defined seek range.  This is only used for
   * VOD content.
   *
   * @param {number} time
   * @export
   */
  setUserSeekStart(time) {
    this.userSeekStart_ = time;
  }


  /**
   * Gets the presentation's current segment availability end time.  Segments
   * starting after this time should be assumed to be unavailable.
   *
   * @return {number} The current segment availability end time, in seconds,
   *   relative to the start of the presentation.  Always returns the
   *   presentation's duration for video-on-demand.
   * @export
   */
  getSegmentAvailabilityEnd() {
    if (!this.isLive() && !this.isInProgress()) {
      // It's a static manifest (can also be a dynamic->static conversion)
      return this.maxSegmentEndTime_ || this.duration_;
    }
    // Can be either live or "in-progress recording" (live with known duration)
    return Math.min(this.getLiveEdge_(), this.duration_);
  }


  /**
   * Gets the seek range start time, offset by the given amount.  This is used
   * to ensure that we don't "fall" back out of the seek window while we are
   * buffering.
   *
   * @param {number} offset The offset to add to the start time for live
   *   streams.
   * @return {number} The current seek start time, in seconds, relative to the
   *   start of the presentation.
   * @export
   */
  getSafeSeekRangeStart(offset) {
    // The earliest known segment time, ignoring segment availability duration.
    const earliestSegmentTime =
        Math.max(this.minSegmentStartTime_, this.userSeekStart_);

    // For VOD, the offset and end time are ignored, and we just return the
    // earliest segment time.  All segments are "safe" in VOD.  However, we
    // should round up to the nearest millisecond to avoid issues like
    // https://github.com/google/shaka-player/issues/2831, in which we tried to
    // seek repeatedly to catch up to the seek range, and never actually
    // "arrived" within it.  The video's currentTime is not as accurate as the
    // JS number representing the earliest segment time for some content.
    if (this.segmentAvailabilityDuration_ == Infinity) {
      return Math.ceil(earliestSegmentTime * 1e3) / 1e3;
    }

    // AKA the live edge for live streams.
    const availabilityEnd = this.getSegmentAvailabilityEnd();

    // The ideal availability start, not considering known segments.
    const availabilityStart =
        availabilityEnd - this.segmentAvailabilityDuration_;

    // Add the offset to the availability start to ensure that we don't fall
    // outside the availability window while we buffer; we don't need to add the
    // offset to earliestSegmentTime since that won't change over time.
    // Also see: https://github.com/google/shaka-player/issues/692
    const desiredStart =
        Math.min(availabilityStart + offset, this.getSeekRangeEnd());
    return Math.max(earliestSegmentTime, desiredStart);
  }


  /**
   * Gets the seek range start time.
   *
   * @return {number}
   * @export
   */
  getSeekRangeStart() {
    return this.getSafeSeekRangeStart(/* offset= */ 0);
  }


  /**
   * Gets the seek range end.
   *
   * @return {number}
   * @export
   */
  getSeekRangeEnd() {
    const useDelay = this.isLive() || this.isInProgress();
    const delay = useDelay ? this.presentationDelay_ : 0;
    return Math.max(0, this.getSegmentAvailabilityEnd() - delay);
  }


  /**
   * True if the presentation start time is being used to calculate the live
   * edge.
   * Using the presentation start time means that the stream may be subject to
   * encoder drift.  At runtime, we will avoid using the presentation start time
   * whenever possible.
   *
   * @return {boolean}
   * @export
   */
  usingPresentationStartTime() {
    // If it's VOD, IPR, or an HLS "event", we are not using the presentation
    // start time.
    if (this.presentationStartTime_ == null) {
      return false;
    }

    // If we have explicit segment times, we're not using the presentation
    // start time.
    if (this.maxSegmentEndTime_ != null && this.autoCorrectDrift_) {
      return false;
    }

    return true;
  }


  /**
   * @return {number} The current presentation time in seconds.
   * @private
   */
  getLiveEdge_() {
    goog.asserts.assert(this.presentationStartTime_ != null,
        'Cannot compute timeline live edge without start time');
    // Date.now() is in milliseconds, from which we compute "now" in seconds.
    const now = (Date.now() + this.clockOffset_) / 1000.0;
    return Math.max(
        0, now - this.maxSegmentDuration_ - this.presentationStartTime_);
  }

  /**
   * Debug only: assert that the timeline parameters make sense for the type
   * of presentation (VOD, IPR, live).
   */
  assertIsValid() {
    if (goog.DEBUG) {
      if (this.isLive()) {
        // Implied by isLive(): infinite and dynamic.
        // Live streams should have a start time.
        goog.asserts.assert(this.presentationStartTime_ != null,
            'Detected as live stream, but does not match our model of live!');
      } else if (this.isInProgress()) {
        // Implied by isInProgress(): finite and dynamic.
        // IPR streams should have a start time, and segments should not expire.
        goog.asserts.assert(this.presentationStartTime_ != null &&
                            this.segmentAvailabilityDuration_ == Infinity,
        'Detected as IPR stream, but does not match our model of IPR!');
      } else {  // VOD
        // VOD segments should not expire and the presentation should be finite
        // and static.
        goog.asserts.assert(this.segmentAvailabilityDuration_ == Infinity &&
                            this.duration_ != Infinity &&
                            this.static_,
        'Detected as VOD stream, but does not match our model of VOD!');
      }
    }
  }
};