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;