summaryrefslogtreecommitdiff
path: root/javascript/videojs/src/js/live-tracker.js
blob: 3e18d73c33771f9c14c1bf762e50feb7ae1f94b9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
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;