live-tracker.js

import Component from './component.js';
import {merge} from './utils/obj.js';
import window from 'global/window';
import * as Fn from './utils/fn.js';

/** @import Player from './player' */

const defaults = {
  trackingThreshold: 20,
  liveTolerance: 15
};

/*
  track when we are at the live edge, and other helpers for live playback */

/**
 * A class for checking live current time and determining when the player
 * is at or behind the live edge.
 */
class LiveTracker extends Component {

  /**
   * Creates an instance of this class.
   *
   * @param {Player} player
   *        The `Player` that this class should be attached to.
   *
   * @param {Object} [options]
   *        The key/value store of player options.
   *
   * @param {number} [options.trackingThreshold=20]
   *        Number of seconds of live window (seekableEnd - seekableStart) that
   *        media needs to have before the liveui will be shown.
   *
   * @param {number} [options.liveTolerance=15]
   *        Number of seconds behind live that we have to be
   *        before we will be considered non-live. Note that this will only
   *        be used when playing at the live edge. This allows large seekable end
   *        changes to not effect whether we are live or not.
   */
  constructor(player, options) {
    // LiveTracker does not need an element
    const options_ = merge(defaults, options, {createEl: false});

    super(player, options_);

    this.trackLiveHandler_ = () => this.trackLive_();
    this.handlePlay_ = (e) => this.handlePlay(e);
    this.handleFirstTimeupdate_ = (e) => this.handleFirstTimeupdate(e);
    this.handleSeeked_ = (e) => this.handleSeeked(e);
    this.seekToLiveEdge_ = (e) => this.seekToLiveEdge(e);

    this.reset_();

    this.on(this.player_, 'durationchange', (e) => this.handleDurationchange(e));
    // we should try to toggle tracking on canplay as native playback engines, like Safari
    // may not have the proper values for things like seekableEnd until then
    this.on(this.player_, 'canplay', () => this.toggleTracking());
  }

  /**
   * all the functionality for tracking when seek end changes
   * and for tracking how far past seek end we should be
   */
  trackLive_() {
    const seekable = this.player_.seekable();

    // skip undefined seekable
    if (!seekable || !seekable.length) {
      return;
    }

    const newTime = Number(window.performance.now().toFixed(4));
    const deltaTime = this.lastTime_ === -1 ? 0 : (newTime - this.lastTime_) / 1000;

    this.lastTime_ = newTime;

    this.pastSeekEnd_ = this.pastSeekEnd() + deltaTime;

    const liveCurrentTime = this.liveCurrentTime();
    const currentTime = this.player_.currentTime();

    // we are behind live if any are true
    // 1. the player is paused
    // 2. the user seeked to a location 2 seconds away from live
    // 3. the difference between live and current time is greater
    //    liveTolerance which defaults to 15s
    let isBehind = this.player_.paused() || this.seekedBehindLive_ ||
      Math.abs(liveCurrentTime - currentTime) > this.options_.liveTolerance;

    // we cannot be behind if
    // 1. until we have not seen a timeupdate yet
    // 2. liveCurrentTime is Infinity, which happens on Android and Native Safari
    if (!this.timeupdateSeen_ || liveCurrentTime === Infinity) {
      isBehind = false;
    }

    if (isBehind !== this.behindLiveEdge_) {
      this.behindLiveEdge_ = isBehind;
      this.trigger('liveedgechange');
    }
  }

  /**
   * handle a durationchange event on the player
   * and start/stop tracking accordingly.
   */
  handleDurationchange() {
    this.toggleTracking();
  }

  /**
   * start/stop tracking
   */
  toggleTracking() {
    if (this.player_.duration() === Infinity && this.liveWindow() >= this.options_.trackingThreshold) {
      if (this.player_.options_.liveui) {
        this.player_.addClass('vjs-liveui');
      }
      this.startTracking();
    } else {
      this.player_.removeClass('vjs-liveui');
      this.stopTracking();
    }
  }

  /**
   * start tracking live playback
   */
  startTracking() {
    if (this.isTracking()) {
      return;
    }

    // If we haven't seen a timeupdate, we need to check whether playback
    // began before this component started tracking. This can happen commonly
    // when using autoplay.
    if (!this.timeupdateSeen_) {
      this.timeupdateSeen_ = this.player_.hasStarted();
    }

    this.trackingInterval_ = this.setInterval(this.trackLiveHandler_, Fn.UPDATE_REFRESH_INTERVAL);
    this.trackLive_();

    this.on(this.player_, ['play', 'pause'], this.trackLiveHandler_);

    if (!this.timeupdateSeen_) {
      this.one(this.player_, 'play', this.handlePlay_);
      this.one(this.player_, 'timeupdate', this.handleFirstTimeupdate_);
    } else {
      this.on(this.player_, 'seeked', this.handleSeeked_);
    }
  }

  /**
   * handle the first timeupdate on the player if it wasn't already playing
   * when live tracker started tracking.
   */
  handleFirstTimeupdate() {
    this.timeupdateSeen_ = true;
    this.on(this.player_, 'seeked', this.handleSeeked_);
  }

  /**
   * Keep track of what time a seek starts, and listen for seeked
   * to find where a seek ends.
   */
  handleSeeked() {
    const timeDiff = Math.abs(this.liveCurrentTime() - this.player_.currentTime());

    this.seekedBehindLive_ = this.nextSeekedFromUser_ && timeDiff > 2;
    this.nextSeekedFromUser_ = false;
    this.trackLive_();
  }

  /**
   * handle the first play on the player, and make sure that we seek
   * right to the live edge.
   */
  handlePlay() {
    this.one(this.player_, 'timeupdate', this.seekToLiveEdge_);
  }

  /**
   * Stop tracking, and set all internal variables to
   * their initial value.
   */
  reset_() {
    this.lastTime_ = -1;
    this.pastSeekEnd_ = 0;
    this.lastSeekEnd_ = -1;
    this.behindLiveEdge_ = true;
    this.timeupdateSeen_ = false;
    this.seekedBehindLive_ = false;
    this.nextSeekedFromUser_ = false;

    this.clearInterval(this.trackingInterval_);
    this.trackingInterval_ = null;

    this.off(this.player_, ['play', 'pause'], this.trackLiveHandler_);
    this.off(this.player_, 'seeked', this.handleSeeked_);
    this.off(this.player_, 'play', this.handlePlay_);
    this.off(this.player_, 'timeupdate', this.handleFirstTimeupdate_);
    this.off(this.player_, 'timeupdate', this.seekToLiveEdge_);
  }

  /**
   * The next seeked event is from the user. Meaning that any seek
   * > 2s behind live will be considered behind live for real and
   * liveTolerance will be ignored.
   */
  nextSeekedFromUser() {
    this.nextSeekedFromUser_ = true;
  }

  /**
   * stop tracking live playback
   */
  stopTracking() {
    if (!this.isTracking()) {
      return;
    }
    this.reset_();
    this.trigger('liveedgechange');
  }

  /**
   * A helper to get the player seekable end
   * so that we don't have to null check everywhere
   *
   * @return {number}
   *         The furthest seekable end or Infinity.
   */
  seekableEnd() {
    const seekable = this.player_.seekable();
    const seekableEnds = [];
    let i = seekable ? seekable.length : 0;

    while (i--) {
      seekableEnds.push(seekable.end(i));
    }

    // grab the furthest seekable end after sorting, or if there are none
    // default to Infinity
    return seekableEnds.length ? seekableEnds.sort()[seekableEnds.length - 1] : Infinity;
  }

  /**
   * A helper to get the player seekable start
   * so that we don't have to null check everywhere
   *
   * @return {number}
   *         The earliest seekable start or 0.
   */
  seekableStart() {
    const seekable = this.player_.seekable();
    const seekableStarts = [];
    let i = seekable ? seekable.length : 0;

    while (i--) {
      seekableStarts.push(seekable.start(i));
    }

    // grab the first seekable start after sorting, or if there are none
    // default to 0
    return seekableStarts.length ? seekableStarts.sort()[0] : 0;
  }

  /**
   * Get the live time window aka
   * the amount of time between seekable start and
   * live current time.
   *
   * @return {number}
   *         The amount of seconds that are seekable in
   *         the live video.
   */
  liveWindow() {
    const liveCurrentTime = this.liveCurrentTime();

    // if liveCurrenTime is Infinity then we don't have a liveWindow at all
    if (liveCurrentTime === Infinity) {
      return 0;
    }

    return liveCurrentTime - this.seekableStart();
  }

  /**
   * Determines if the player is live, only checks if this component
   * is tracking live playback or not
   *
   * @return {boolean}
   *         Whether liveTracker is tracking
   */
  isLive() {
    return this.isTracking();
  }

  /**
   * Determines if currentTime is at the live edge and won't fall behind
   * on each seekableendchange
   *
   * @return {boolean}
   *         Whether playback is at the live edge
   */
  atLiveEdge() {
    return !this.behindLiveEdge();
  }

  /**
   * get what we expect the live current time to be
   *
   * @return {number}
   *         The expected live current time
   */
  liveCurrentTime() {
    return this.pastSeekEnd() + this.seekableEnd();
  }

  /**
   * The number of seconds that have occurred after seekable end
   * changed. This will be reset to 0 once seekable end changes.
   *
   * @return {number}
   *         Seconds past the current seekable end
   */
  pastSeekEnd() {
    const seekableEnd = this.seekableEnd();

    if (this.lastSeekEnd_ !== -1 && seekableEnd !== this.lastSeekEnd_) {
      this.pastSeekEnd_ = 0;
    }
    this.lastSeekEnd_ = seekableEnd;
    return this.pastSeekEnd_;
  }

  /**
   * If we are currently behind the live edge, aka currentTime will be
   * behind on a seekableendchange
   *
   * @return {boolean}
   *         If we are behind the live edge
   */
  behindLiveEdge() {
    return this.behindLiveEdge_;
  }

  /**
   * Whether live tracker is currently tracking or not.
   */
  isTracking() {
    return typeof this.trackingInterval_ === 'number';
  }

  /**
   * Seek to the live edge if we are behind the live edge
   */
  seekToLiveEdge() {
    this.seekedBehindLive_ = false;
    if (this.atLiveEdge()) {
      return;
    }
    this.nextSeekedFromUser_ = false;
    this.player_.currentTime(this.liveCurrentTime());

  }

  /**
   * Dispose of liveTracker
   */
  dispose() {
    this.stopTracking();
    super.dispose();
  }
}

Component.registerComponent('LiveTracker', LiveTracker);
export default LiveTracker;