Source: lib/media/presentation_timeline.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.PresentationTimeline');

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


/**
 * Creates a PresentationTimeline.
 *
 * @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
 * @struct
 * @export
 */
shaka.media.PresentationTimeline = function(
    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
 */
shaka.media.PresentationTimeline.prototype.getDuration = function() {
  return this.duration_;
};


/**
 * @return {number} The presentation's max segment duration in seconds.
   * @export
 */
shaka.media.PresentationTimeline.prototype.getMaxSegmentDuration = function() {
  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
 */
shaka.media.PresentationTimeline.prototype.setDuration = function(duration) {
  goog.asserts.assert(duration > 0, 'duration must be > 0');
  this.duration_ = duration;
};


/**
 * @return {?number} The presentation's start time in seconds.
 * @export
 */
shaka.media.PresentationTimeline.prototype.getPresentationStartTime =
    function() {
  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
 */
shaka.media.PresentationTimeline.prototype.setClockOffset = function(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
 */
shaka.media.PresentationTimeline.prototype.setStatic = function(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
 */
shaka.media.PresentationTimeline.prototype.setSegmentAvailabilityDuration =
    function(segmentAvailabilityDuration) {
  goog.asserts.assert(segmentAvailabilityDuration >= 0,
                      'segmentAvailabilityDuration must be >= 0');
  this.segmentAvailabilityDuration_ = segmentAvailabilityDuration;
};


/**
 * Sets the presentation delay in seconds.
 *
 * @param {number} delay
 * @export
 */
shaka.media.PresentationTimeline.prototype.setDelay = function(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
 */
shaka.media.PresentationTimeline.prototype.getDelay = function() {
  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
 * @param {number} periodStart
 * @export
 */
shaka.media.PresentationTimeline.prototype.notifySegments = function(
    references, periodStart) {
  if (references.length == 0) {
    return;
  }

  // TODO: Make SegmentReferences use timestamps in the presentation timeline,
  // not the period timeline.
  const firstReferenceStartTime = references[0].startTime + periodStart;
  const lastReferenceEndTime =
      references[references.length - 1].endTime + periodStart;

  this.notifyMinSegmentStartTime(firstReferenceStartTime);

  this.maxSegmentDuration_ = references.reduce(
      function(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.
    let 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
 */
shaka.media.PresentationTimeline.prototype.notifyMinSegmentStartTime = function(
    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
 */
shaka.media.PresentationTimeline.prototype.notifyMaxSegmentDuration = function(
    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
 */
shaka.media.PresentationTimeline.prototype.offset = function(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
 */
shaka.media.PresentationTimeline.prototype.isLive = function() {
  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
 */
shaka.media.PresentationTimeline.prototype.isInProgress = function() {
  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
 */
shaka.media.PresentationTimeline.prototype.getSegmentAvailabilityStart =
    function() {
  goog.asserts.assert(this.segmentAvailabilityDuration_ >= 0,
                      'The availability duration should be positive');

  if (this.segmentAvailabilityDuration_ == Infinity) {
    return this.userSeekStart_;
  }

  let end = this.getSegmentAvailabilityEnd();
  let 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
 */
shaka.media.PresentationTimeline.prototype.setUserSeekStart =
    function(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
 */
shaka.media.PresentationTimeline.prototype.getSegmentAvailabilityEnd =
    function() {
  if (!this.isLive() && !this.isInProgress()) {
    return this.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
 */
shaka.media.PresentationTimeline.prototype.getSafeSeekRangeStart = function(
    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
 */
shaka.media.PresentationTimeline.prototype.getSeekRangeStart = function() {
  return this.getSafeSeekRangeStart(/* offset */ 0);
};


/**
 * Gets the seek range end.
 *
 * @return {number}
 * @export
 */
shaka.media.PresentationTimeline.prototype.getSeekRangeEnd = function() {
  let useDelay = this.isLive() || this.isInProgress();
  let 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
 */
shaka.media.PresentationTimeline.prototype.usingPresentationStartTime =
    function() {
  // 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
 */
shaka.media.PresentationTimeline.prototype.getLiveEdge_ = function() {
  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.
  let now = (Date.now() + this.clockOffset_) / 1000.0;
  return Math.max(
      0, now - this.maxSegmentDuration_ - this.presentationStartTime_);
};


if (goog.DEBUG) {
  /**
   * Debug only: assert that the timeline parameters make sense for the type of
   *   presentation (VOD, IPR, live).
   */
  shaka.media.PresentationTimeline.prototype.assertIsValid = function() {
    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!');
    }
  };
}