1 | /**
|
2 | * @file segment-loader.js
|
3 | */
|
4 | import Playlist from './playlist';
|
5 | import videojs from 'video.js';
|
6 | import SourceUpdater from './source-updater';
|
7 | import Config from './config';
|
8 | import window from 'global/window';
|
9 | import removeCuesFromTrack from
|
10 | 'videojs-contrib-media-sources/es5/remove-cues-from-track.js';
|
11 | import { initSegmentId } from './bin-utils';
|
12 | import {mediaSegmentRequest, REQUEST_ERRORS} from './media-segment-request';
|
13 | import { TIME_FUDGE_FACTOR, timeUntilRebuffer as timeUntilRebuffer_ } from './ranges';
|
14 | import { minRebufferMaxBandwidthSelector } from './playlist-selectors';
|
15 |
|
16 | // in ms
|
17 | const CHECK_BUFFER_DELAY = 500;
|
18 |
|
19 | /**
|
20 | * Determines if we should call endOfStream on the media source based
|
21 | * on the state of the buffer or if appened segment was the final
|
22 | * segment in the playlist.
|
23 | *
|
24 | * @param {Object} playlist a media playlist object
|
25 | * @param {Object} mediaSource the MediaSource object
|
26 | * @param {Number} segmentIndex the index of segment we last appended
|
27 | * @returns {Boolean} do we need to call endOfStream on the MediaSource
|
28 | */
|
29 | const detectEndOfStream = function(playlist, mediaSource, segmentIndex) {
|
30 | if (!playlist || !mediaSource) {
|
31 | return false;
|
32 | }
|
33 |
|
34 | let segments = playlist.segments;
|
35 |
|
36 | // determine a few boolean values to help make the branch below easier
|
37 | // to read
|
38 | let appendedLastSegment = segmentIndex === segments.length;
|
39 |
|
40 | // if we've buffered to the end of the video, we need to call endOfStream
|
41 | // so that MediaSources can trigger the `ended` event when it runs out of
|
42 | // buffered data instead of waiting for me
|
43 | return playlist.endList &&
|
44 | mediaSource.readyState === 'open' &&
|
45 | appendedLastSegment;
|
46 | };
|
47 |
|
48 | const finite = (num) => typeof num === 'number' && isFinite(num);
|
49 |
|
50 | /**
|
51 | * An object that manages segment loading and appending.
|
52 | *
|
53 | * @class SegmentLoader
|
54 | * @param {Object} options required and optional options
|
55 | * @extends videojs.EventTarget
|
56 | */
|
57 | export default class SegmentLoader extends videojs.EventTarget {
|
58 | constructor(settings, options = {}) {
|
59 | super();
|
60 | // check pre-conditions
|
61 | if (!settings) {
|
62 | throw new TypeError('Initialization settings are required');
|
63 | }
|
64 | if (typeof settings.currentTime !== 'function') {
|
65 | throw new TypeError('No currentTime getter specified');
|
66 | }
|
67 | if (!settings.mediaSource) {
|
68 | throw new TypeError('No MediaSource specified');
|
69 | }
|
70 | // public properties
|
71 | this.state = 'INIT';
|
72 | this.bandwidth = settings.bandwidth;
|
73 | this.throughput = {rate: 0, count: 0};
|
74 | this.roundTrip = NaN;
|
75 | this.resetStats_();
|
76 | this.mediaIndex = null;
|
77 |
|
78 | // private settings
|
79 | this.hasPlayed_ = settings.hasPlayed;
|
80 | this.currentTime_ = settings.currentTime;
|
81 | this.seekable_ = settings.seekable;
|
82 | this.seeking_ = settings.seeking;
|
83 | this.duration_ = settings.duration;
|
84 | this.mediaSource_ = settings.mediaSource;
|
85 | this.hls_ = settings.hls;
|
86 | this.loaderType_ = settings.loaderType;
|
87 | this.segmentMetadataTrack_ = settings.segmentMetadataTrack;
|
88 | this.goalBufferLength_ = settings.goalBufferLength;
|
89 |
|
90 | // private instance variables
|
91 | this.checkBufferTimeout_ = null;
|
92 | this.error_ = void 0;
|
93 | this.currentTimeline_ = -1;
|
94 | this.pendingSegment_ = null;
|
95 | this.mimeType_ = null;
|
96 | this.sourceUpdater_ = null;
|
97 | this.xhrOptions_ = null;
|
98 |
|
99 | // Fragmented mp4 playback
|
100 | this.activeInitSegmentId_ = null;
|
101 | this.initSegments_ = {};
|
102 |
|
103 | this.decrypter_ = settings.decrypter;
|
104 |
|
105 | // Manages the tracking and generation of sync-points, mappings
|
106 | // between a time in the display time and a segment index within
|
107 | // a playlist
|
108 | this.syncController_ = settings.syncController;
|
109 | this.syncPoint_ = {
|
110 | segmentIndex: 0,
|
111 | time: 0
|
112 | };
|
113 |
|
114 | this.syncController_.on('syncinfoupdate', () => this.trigger('syncinfoupdate'));
|
115 |
|
116 | this.mediaSource_.addEventListener('sourceopen', () => this.ended_ = false);
|
117 |
|
118 | // ...for determining the fetch location
|
119 | this.fetchAtBuffer_ = false;
|
120 |
|
121 | if (options.debug) {
|
122 | this.logger_ = videojs.log.bind(videojs, 'segment-loader', this.loaderType_, '->');
|
123 | }
|
124 | }
|
125 |
|
126 | /**
|
127 | * reset all of our media stats
|
128 | *
|
129 | * @private
|
130 | */
|
131 | resetStats_() {
|
132 | this.mediaBytesTransferred = 0;
|
133 | this.mediaRequests = 0;
|
134 | this.mediaRequestsAborted = 0;
|
135 | this.mediaRequestsTimedout = 0;
|
136 | this.mediaRequestsErrored = 0;
|
137 | this.mediaTransferDuration = 0;
|
138 | this.mediaSecondsLoaded = 0;
|
139 | }
|
140 |
|
141 | /**
|
142 | * dispose of the SegmentLoader and reset to the default state
|
143 | */
|
144 | dispose() {
|
145 | this.state = 'DISPOSED';
|
146 | this.pause();
|
147 | this.abort_();
|
148 | if (this.sourceUpdater_) {
|
149 | this.sourceUpdater_.dispose();
|
150 | }
|
151 | this.resetStats_();
|
152 | }
|
153 |
|
154 | /**
|
155 | * abort anything that is currently doing on with the SegmentLoader
|
156 | * and reset to a default state
|
157 | */
|
158 | abort() {
|
159 | if (this.state !== 'WAITING') {
|
160 | if (this.pendingSegment_) {
|
161 | this.pendingSegment_ = null;
|
162 | }
|
163 | return;
|
164 | }
|
165 |
|
166 | this.abort_();
|
167 |
|
168 | // We aborted the requests we were waiting on, so reset the loader's state to READY
|
169 | // since we are no longer "waiting" on any requests. XHR callback is not always run
|
170 | // when the request is aborted. This will prevent the loader from being stuck in the
|
171 | // WAITING state indefinitely.
|
172 | this.state = 'READY';
|
173 |
|
174 | // don't wait for buffer check timeouts to begin fetching the
|
175 | // next segment
|
176 | if (!this.paused()) {
|
177 | this.monitorBuffer_();
|
178 | }
|
179 | }
|
180 |
|
181 | /**
|
182 | * abort all pending xhr requests and null any pending segements
|
183 | *
|
184 | * @private
|
185 | */
|
186 | abort_() {
|
187 | if (this.pendingSegment_) {
|
188 | this.pendingSegment_.abortRequests();
|
189 | }
|
190 |
|
191 | // clear out the segment being processed
|
192 | this.pendingSegment_ = null;
|
193 | }
|
194 |
|
195 | /**
|
196 | * set an error on the segment loader and null out any pending segements
|
197 | *
|
198 | * @param {Error} error the error to set on the SegmentLoader
|
199 | * @return {Error} the error that was set or that is currently set
|
200 | */
|
201 | error(error) {
|
202 | if (typeof error !== 'undefined') {
|
203 | this.error_ = error;
|
204 | }
|
205 |
|
206 | this.pendingSegment_ = null;
|
207 | return this.error_;
|
208 | }
|
209 |
|
210 | endOfStream() {
|
211 | this.ended_ = true;
|
212 | this.pause();
|
213 | this.trigger('ended');
|
214 | }
|
215 |
|
216 | /**
|
217 | * Indicates which time ranges are buffered
|
218 | *
|
219 | * @return {TimeRange}
|
220 | * TimeRange object representing the current buffered ranges
|
221 | */
|
222 | buffered_() {
|
223 | if (!this.sourceUpdater_) {
|
224 | return videojs.createTimeRanges();
|
225 | }
|
226 |
|
227 | return this.sourceUpdater_.buffered();
|
228 | }
|
229 |
|
230 | /**
|
231 | * Gets and sets init segment for the provided map
|
232 | *
|
233 | * @param {Object} map
|
234 | * The map object representing the init segment to get or set
|
235 | * @param {Boolean=} set
|
236 | * If true, the init segment for the provided map should be saved
|
237 | * @return {Object}
|
238 | * map object for desired init segment
|
239 | */
|
240 | initSegment(map, set = false) {
|
241 | if (!map) {
|
242 | return null;
|
243 | }
|
244 |
|
245 | const id = initSegmentId(map);
|
246 | let storedMap = this.initSegments_[id];
|
247 |
|
248 | if (set && !storedMap && map.bytes) {
|
249 | this.initSegments_[id] = storedMap = {
|
250 | resolvedUri: map.resolvedUri,
|
251 | byterange: map.byterange,
|
252 | bytes: map.bytes
|
253 | };
|
254 | }
|
255 |
|
256 | return storedMap || map;
|
257 | }
|
258 |
|
259 | /**
|
260 | * Returns true if all configuration required for loading is present, otherwise false.
|
261 | *
|
262 | * @return {Boolean} True if the all configuration is ready for loading
|
263 | * @private
|
264 | */
|
265 | couldBeginLoading_() {
|
266 | return this.playlist_ &&
|
267 | // the source updater is created when init_ is called, so either having a
|
268 | // source updater or being in the INIT state with a mimeType is enough
|
269 | // to say we have all the needed configuration to start loading.
|
270 | (this.sourceUpdater_ || (this.mimeType_ && this.state === 'INIT')) &&
|
271 | !this.paused();
|
272 | }
|
273 |
|
274 | /**
|
275 | * load a playlist and start to fill the buffer
|
276 | */
|
277 | load() {
|
278 | // un-pause
|
279 | this.monitorBuffer_();
|
280 |
|
281 | // if we don't have a playlist yet, keep waiting for one to be
|
282 | // specified
|
283 | if (!this.playlist_) {
|
284 | return;
|
285 | }
|
286 |
|
287 | // not sure if this is the best place for this
|
288 | this.syncController_.setDateTimeMapping(this.playlist_);
|
289 |
|
290 | // if all the configuration is ready, initialize and begin loading
|
291 | if (this.state === 'INIT' && this.couldBeginLoading_()) {
|
292 | return this.init_();
|
293 | }
|
294 |
|
295 | // if we're in the middle of processing a segment already, don't
|
296 | // kick off an additional segment request
|
297 | if (!this.couldBeginLoading_() ||
|
298 | (this.state !== 'READY' &&
|
299 | this.state !== 'INIT')) {
|
300 | return;
|
301 | }
|
302 |
|
303 | this.state = 'READY';
|
304 | }
|
305 |
|
306 | /**
|
307 | * Once all the starting parameters have been specified, begin
|
308 | * operation. This method should only be invoked from the INIT
|
309 | * state.
|
310 | *
|
311 | * @private
|
312 | */
|
313 | init_() {
|
314 | this.state = 'READY';
|
315 | this.sourceUpdater_ = new SourceUpdater(this.mediaSource_, this.mimeType_);
|
316 | this.resetEverything();
|
317 | return this.monitorBuffer_();
|
318 | }
|
319 |
|
320 | /**
|
321 | * set a playlist on the segment loader
|
322 | *
|
323 | * @param {PlaylistLoader} media the playlist to set on the segment loader
|
324 | */
|
325 | playlist(newPlaylist, options = {}) {
|
326 | if (!newPlaylist) {
|
327 | return;
|
328 | }
|
329 |
|
330 | let oldPlaylist = this.playlist_;
|
331 | let segmentInfo = this.pendingSegment_;
|
332 |
|
333 | this.playlist_ = newPlaylist;
|
334 | this.xhrOptions_ = options;
|
335 |
|
336 | // when we haven't started playing yet, the start of a live playlist
|
337 | // is always our zero-time so force a sync update each time the playlist
|
338 | // is refreshed from the server
|
339 | if (!this.hasPlayed_()) {
|
340 | newPlaylist.syncInfo = {
|
341 | mediaSequence: newPlaylist.mediaSequence,
|
342 | time: 0
|
343 | };
|
344 | }
|
345 |
|
346 | // in VOD, this is always a rendition switch (or we updated our syncInfo above)
|
347 | // in LIVE, we always want to update with new playlists (including refreshes)
|
348 | this.trigger('syncinfoupdate');
|
349 |
|
350 | // if we were unpaused but waiting for a playlist, start
|
351 | // buffering now
|
352 | if (this.state === 'INIT' && this.couldBeginLoading_()) {
|
353 | return this.init_();
|
354 | }
|
355 |
|
356 | if (!oldPlaylist || oldPlaylist.uri !== newPlaylist.uri) {
|
357 | if (this.mediaIndex !== null) {
|
358 | // we must "resync" the segment loader when we switch renditions and
|
359 | // the segment loader is already synced to the previous rendition
|
360 | this.resyncLoader();
|
361 | }
|
362 |
|
363 | // the rest of this function depends on `oldPlaylist` being defined
|
364 | return;
|
365 | }
|
366 |
|
367 | // we reloaded the same playlist so we are in a live scenario
|
368 | // and we will likely need to adjust the mediaIndex
|
369 | let mediaSequenceDiff = newPlaylist.mediaSequence - oldPlaylist.mediaSequence;
|
370 |
|
371 | this.logger_('mediaSequenceDiff', mediaSequenceDiff);
|
372 |
|
373 | // update the mediaIndex on the SegmentLoader
|
374 | // this is important because we can abort a request and this value must be
|
375 | // equal to the last appended mediaIndex
|
376 | if (this.mediaIndex !== null) {
|
377 | this.mediaIndex -= mediaSequenceDiff;
|
378 | }
|
379 |
|
380 | // update the mediaIndex on the SegmentInfo object
|
381 | // this is important because we will update this.mediaIndex with this value
|
382 | // in `handleUpdateEnd_` after the segment has been successfully appended
|
383 | if (segmentInfo) {
|
384 | segmentInfo.mediaIndex -= mediaSequenceDiff;
|
385 |
|
386 | // we need to update the referenced segment so that timing information is
|
387 | // saved for the new playlist's segment, however, if the segment fell off the
|
388 | // playlist, we can leave the old reference and just lose the timing info
|
389 | if (segmentInfo.mediaIndex >= 0) {
|
390 | segmentInfo.segment = newPlaylist.segments[segmentInfo.mediaIndex];
|
391 | }
|
392 | }
|
393 |
|
394 | this.syncController_.saveExpiredSegmentInfo(oldPlaylist, newPlaylist);
|
395 | }
|
396 |
|
397 | /**
|
398 | * Prevent the loader from fetching additional segments. If there
|
399 | * is a segment request outstanding, it will finish processing
|
400 | * before the loader halts. A segment loader can be unpaused by
|
401 | * calling load().
|
402 | */
|
403 | pause() {
|
404 | if (this.checkBufferTimeout_) {
|
405 | window.clearTimeout(this.checkBufferTimeout_);
|
406 |
|
407 | this.checkBufferTimeout_ = null;
|
408 | }
|
409 | }
|
410 |
|
411 | /**
|
412 | * Returns whether the segment loader is fetching additional
|
413 | * segments when given the opportunity. This property can be
|
414 | * modified through calls to pause() and load().
|
415 | */
|
416 | paused() {
|
417 | return this.checkBufferTimeout_ === null;
|
418 | }
|
419 |
|
420 | /**
|
421 | * create/set the following mimetype on the SourceBuffer through a
|
422 | * SourceUpdater
|
423 | *
|
424 | * @param {String} mimeType the mime type string to use
|
425 | */
|
426 | mimeType(mimeType) {
|
427 | if (this.mimeType_) {
|
428 | return;
|
429 | }
|
430 |
|
431 | this.mimeType_ = mimeType;
|
432 | // if we were unpaused but waiting for a sourceUpdater, start
|
433 | // buffering now
|
434 | if (this.state === 'INIT' && this.couldBeginLoading_()) {
|
435 | this.init_();
|
436 | }
|
437 | }
|
438 |
|
439 | /**
|
440 | * Delete all the buffered data and reset the SegmentLoader
|
441 | */
|
442 | resetEverything() {
|
443 | this.ended_ = false;
|
444 | this.resetLoader();
|
445 | this.remove(0, Infinity);
|
446 | }
|
447 |
|
448 | /**
|
449 | * Force the SegmentLoader to resync and start loading around the currentTime instead
|
450 | * of starting at the end of the buffer
|
451 | *
|
452 | * Useful for fast quality changes
|
453 | */
|
454 | resetLoader() {
|
455 | this.fetchAtBuffer_ = false;
|
456 | this.resyncLoader();
|
457 | }
|
458 |
|
459 | /**
|
460 | * Force the SegmentLoader to restart synchronization and make a conservative guess
|
461 | * before returning to the simple walk-forward method
|
462 | */
|
463 | resyncLoader() {
|
464 | this.mediaIndex = null;
|
465 | this.syncPoint_ = null;
|
466 | this.abort();
|
467 | }
|
468 |
|
469 | /**
|
470 | * Remove any data in the source buffer between start and end times
|
471 | * @param {Number} start - the start time of the region to remove from the buffer
|
472 | * @param {Number} end - the end time of the region to remove from the buffer
|
473 | */
|
474 | remove(start, end) {
|
475 | if (this.sourceUpdater_) {
|
476 | this.sourceUpdater_.remove(start, end);
|
477 | }
|
478 | removeCuesFromTrack(start, end, this.segmentMetadataTrack_);
|
479 | }
|
480 |
|
481 | /**
|
482 | * (re-)schedule monitorBufferTick_ to run as soon as possible
|
483 | *
|
484 | * @private
|
485 | */
|
486 | monitorBuffer_() {
|
487 | if (this.checkBufferTimeout_) {
|
488 | window.clearTimeout(this.checkBufferTimeout_);
|
489 | }
|
490 |
|
491 | this.checkBufferTimeout_ = window.setTimeout(this.monitorBufferTick_.bind(this), 1);
|
492 | }
|
493 |
|
494 | /**
|
495 | * As long as the SegmentLoader is in the READY state, periodically
|
496 | * invoke fillBuffer_().
|
497 | *
|
498 | * @private
|
499 | */
|
500 | monitorBufferTick_() {
|
501 | if (this.state === 'READY') {
|
502 | this.fillBuffer_();
|
503 | }
|
504 |
|
505 | if (this.checkBufferTimeout_) {
|
506 | window.clearTimeout(this.checkBufferTimeout_);
|
507 | }
|
508 |
|
509 | this.checkBufferTimeout_ = window.setTimeout(this.monitorBufferTick_.bind(this),
|
510 | CHECK_BUFFER_DELAY);
|
511 | }
|
512 |
|
513 | /**
|
514 | * fill the buffer with segements unless the sourceBuffers are
|
515 | * currently updating
|
516 | *
|
517 | * Note: this function should only ever be called by monitorBuffer_
|
518 | * and never directly
|
519 | *
|
520 | * @private
|
521 | */
|
522 | fillBuffer_() {
|
523 | if (this.sourceUpdater_.updating()) {
|
524 | return;
|
525 | }
|
526 |
|
527 | if (!this.syncPoint_) {
|
528 | this.syncPoint_ = this.syncController_.getSyncPoint(this.playlist_,
|
529 | this.duration_(),
|
530 | this.currentTimeline_,
|
531 | this.currentTime_());
|
532 | }
|
533 |
|
534 | // see if we need to begin loading immediately
|
535 | let segmentInfo = this.checkBuffer_(this.buffered_(),
|
536 | this.playlist_,
|
537 | this.mediaIndex,
|
538 | this.hasPlayed_(),
|
539 | this.currentTime_(),
|
540 | this.syncPoint_);
|
541 |
|
542 | if (!segmentInfo) {
|
543 | return;
|
544 | }
|
545 |
|
546 | let isEndOfStream = detectEndOfStream(this.playlist_,
|
547 | this.mediaSource_,
|
548 | segmentInfo.mediaIndex);
|
549 |
|
550 | if (isEndOfStream) {
|
551 | this.endOfStream();
|
552 | return;
|
553 | }
|
554 |
|
555 | if (segmentInfo.mediaIndex === this.playlist_.segments.length - 1 &&
|
556 | this.mediaSource_.readyState === 'ended' &&
|
557 | !this.seeking_()) {
|
558 | return;
|
559 | }
|
560 |
|
561 | // We will need to change timestampOffset of the sourceBuffer if either of
|
562 | // the following conditions are true:
|
563 | // - The segment.timeline !== this.currentTimeline
|
564 | // (we are crossing a discontinuity somehow)
|
565 | // - The "timestampOffset" for the start of this segment is less than
|
566 | // the currently set timestampOffset
|
567 | if (segmentInfo.timeline !== this.currentTimeline_ ||
|
568 | ((segmentInfo.startOfSegment !== null) &&
|
569 | segmentInfo.startOfSegment < this.sourceUpdater_.timestampOffset())) {
|
570 | this.syncController_.reset();
|
571 | segmentInfo.timestampOffset = segmentInfo.startOfSegment;
|
572 | }
|
573 |
|
574 | this.loadSegment_(segmentInfo);
|
575 | }
|
576 |
|
577 | /**
|
578 | * Determines what segment request should be made, given current playback
|
579 | * state.
|
580 | *
|
581 | * @param {TimeRanges} buffered - the state of the buffer
|
582 | * @param {Object} playlist - the playlist object to fetch segments from
|
583 | * @param {Number} mediaIndex - the previous mediaIndex fetched or null
|
584 | * @param {Boolean} hasPlayed - a flag indicating whether we have played or not
|
585 | * @param {Number} currentTime - the playback position in seconds
|
586 | * @param {Object} syncPoint - a segment info object that describes the
|
587 | * @returns {Object} a segment request object that describes the segment to load
|
588 | */
|
589 | checkBuffer_(buffered, playlist, mediaIndex, hasPlayed, currentTime, syncPoint) {
|
590 | let lastBufferedEnd = 0;
|
591 | let startOfSegment;
|
592 |
|
593 | if (buffered.length) {
|
594 | lastBufferedEnd = buffered.end(buffered.length - 1);
|
595 | }
|
596 |
|
597 | let bufferedTime = Math.max(0, lastBufferedEnd - currentTime);
|
598 |
|
599 | if (!playlist.segments.length) {
|
600 | return null;
|
601 | }
|
602 |
|
603 | // if there is plenty of content buffered, and the video has
|
604 | // been played before relax for awhile
|
605 | if (bufferedTime >= this.goalBufferLength_()) {
|
606 | return null;
|
607 | }
|
608 |
|
609 | // if the video has not yet played once, and we already have
|
610 | // one segment downloaded do nothing
|
611 | if (!hasPlayed && bufferedTime >= 1) {
|
612 | return null;
|
613 | }
|
614 |
|
615 | this.logger_('checkBuffer_',
|
616 | 'mediaIndex:', mediaIndex,
|
617 | 'hasPlayed:', hasPlayed,
|
618 | 'currentTime:', currentTime,
|
619 | 'syncPoint:', syncPoint,
|
620 | 'fetchAtBuffer:', this.fetchAtBuffer_,
|
621 | 'bufferedTime:', bufferedTime);
|
622 |
|
623 | // When the syncPoint is null, there is no way of determining a good
|
624 | // conservative segment index to fetch from
|
625 | // The best thing to do here is to get the kind of sync-point data by
|
626 | // making a request
|
627 | if (syncPoint === null) {
|
628 | mediaIndex = this.getSyncSegmentCandidate_(playlist);
|
629 | this.logger_('getSync', 'mediaIndex:', mediaIndex);
|
630 | return this.generateSegmentInfo_(playlist, mediaIndex, null, true);
|
631 | }
|
632 |
|
633 | // Under normal playback conditions fetching is a simple walk forward
|
634 | if (mediaIndex !== null) {
|
635 | this.logger_('walkForward', 'mediaIndex:', mediaIndex + 1);
|
636 | let segment = playlist.segments[mediaIndex];
|
637 |
|
638 | if (segment && segment.end) {
|
639 | startOfSegment = segment.end;
|
640 | } else {
|
641 | startOfSegment = lastBufferedEnd;
|
642 | }
|
643 | return this.generateSegmentInfo_(playlist, mediaIndex + 1, startOfSegment, false);
|
644 | }
|
645 |
|
646 | // There is a sync-point but the lack of a mediaIndex indicates that
|
647 | // we need to make a good conservative guess about which segment to
|
648 | // fetch
|
649 | if (this.fetchAtBuffer_) {
|
650 | // Find the segment containing the end of the buffer
|
651 | let mediaSourceInfo = Playlist.getMediaInfoForTime(playlist,
|
652 | lastBufferedEnd,
|
653 | syncPoint.segmentIndex,
|
654 | syncPoint.time);
|
655 |
|
656 | mediaIndex = mediaSourceInfo.mediaIndex;
|
657 | startOfSegment = mediaSourceInfo.startTime;
|
658 | } else {
|
659 | // Find the segment containing currentTime
|
660 | let mediaSourceInfo = Playlist.getMediaInfoForTime(playlist,
|
661 | currentTime,
|
662 | syncPoint.segmentIndex,
|
663 | syncPoint.time);
|
664 |
|
665 | mediaIndex = mediaSourceInfo.mediaIndex;
|
666 | startOfSegment = mediaSourceInfo.startTime;
|
667 | }
|
668 | this.logger_('getMediaIndexForTime',
|
669 | 'mediaIndex:', mediaIndex,
|
670 | 'startOfSegment:', startOfSegment);
|
671 |
|
672 | return this.generateSegmentInfo_(playlist, mediaIndex, startOfSegment, false);
|
673 | }
|
674 |
|
675 | /**
|
676 | * The segment loader has no recourse except to fetch a segment in the
|
677 | * current playlist and use the internal timestamps in that segment to
|
678 | * generate a syncPoint. This function returns a good candidate index
|
679 | * for that process.
|
680 | *
|
681 | * @param {Object} playlist - the playlist object to look for a
|
682 | * @returns {Number} An index of a segment from the playlist to load
|
683 | */
|
684 | getSyncSegmentCandidate_(playlist) {
|
685 | if (this.currentTimeline_ === -1) {
|
686 | return 0;
|
687 | }
|
688 |
|
689 | let segmentIndexArray = playlist.segments
|
690 | .map((s, i) => {
|
691 | return {
|
692 | timeline: s.timeline,
|
693 | segmentIndex: i
|
694 | };
|
695 | }).filter(s => s.timeline === this.currentTimeline_);
|
696 |
|
697 | if (segmentIndexArray.length) {
|
698 | return segmentIndexArray[Math.min(segmentIndexArray.length - 1, 1)].segmentIndex;
|
699 | }
|
700 |
|
701 | return Math.max(playlist.segments.length - 1, 0);
|
702 | }
|
703 |
|
704 | generateSegmentInfo_(playlist, mediaIndex, startOfSegment, isSyncRequest) {
|
705 | if (mediaIndex < 0 || mediaIndex >= playlist.segments.length) {
|
706 | return null;
|
707 | }
|
708 |
|
709 | let segment = playlist.segments[mediaIndex];
|
710 |
|
711 | return {
|
712 | requestId: 'segment-loader-' + Math.random(),
|
713 | // resolve the segment URL relative to the playlist
|
714 | uri: segment.resolvedUri,
|
715 | // the segment's mediaIndex at the time it was requested
|
716 | mediaIndex,
|
717 | // whether or not to update the SegmentLoader's state with this
|
718 | // segment's mediaIndex
|
719 | isSyncRequest,
|
720 | startOfSegment,
|
721 | // the segment's playlist
|
722 | playlist,
|
723 | // unencrypted bytes of the segment
|
724 | bytes: null,
|
725 | // when a key is defined for this segment, the encrypted bytes
|
726 | encryptedBytes: null,
|
727 | // The target timestampOffset for this segment when we append it
|
728 | // to the source buffer
|
729 | timestampOffset: null,
|
730 | // The timeline that the segment is in
|
731 | timeline: segment.timeline,
|
732 | // The expected duration of the segment in seconds
|
733 | duration: segment.duration,
|
734 | // retain the segment in case the playlist updates while doing an async process
|
735 | segment
|
736 | };
|
737 | }
|
738 |
|
739 | /**
|
740 | * Determines if the network has enough bandwidth to complete the current segment
|
741 | * request in a timely manner. If not, the request will be aborted early and bandwidth
|
742 | * updated to trigger a playlist switch.
|
743 | *
|
744 | * @param {Object} stats
|
745 | * Object containing stats about the request timing and size
|
746 | * @return {Boolean} True if the request was aborted, false otherwise
|
747 | * @private
|
748 | */
|
749 | abortRequestEarly_(stats) {
|
750 | if (this.hls_.tech_.paused() ||
|
751 | // Don't abort if the current playlist is on the lowestEnabledRendition
|
752 | // TODO: Replace using timeout with a boolean indicating whether this playlist is
|
753 | // the lowestEnabledRendition.
|
754 | !this.xhrOptions_.timeout ||
|
755 | // Don't abort if we have no bandwidth information to estimate segment sizes
|
756 | !(this.playlist_.attributes && this.playlist_.attributes.BANDWIDTH)) {
|
757 | return false;
|
758 | }
|
759 |
|
760 | // Wait at least 1 second since the first byte of data has been received before
|
761 | // using the calculated bandwidth from the progress event to allow the bitrate
|
762 | // to stabilize
|
763 | if (Date.now() - (stats.firstBytesReceivedAt || Date.now()) < 1000) {
|
764 | return false;
|
765 | }
|
766 |
|
767 | const currentTime = this.currentTime_();
|
768 | const measuredBandwidth = stats.bandwidth;
|
769 | const segmentDuration = this.pendingSegment_.duration;
|
770 |
|
771 | const requestTimeRemaining =
|
772 | Playlist.estimateSegmentRequestTime(segmentDuration,
|
773 | measuredBandwidth,
|
774 | this.playlist_,
|
775 | stats.bytesReceived);
|
776 |
|
777 | // Subtract 1 from the timeUntilRebuffer so we still consider an early abort
|
778 | // if we are only left with less than 1 second when the request completes.
|
779 | // A negative timeUntilRebuffering indicates we are already rebuffering
|
780 | const timeUntilRebuffer = timeUntilRebuffer_(this.buffered_(),
|
781 | currentTime,
|
782 | this.hls_.tech_.playbackRate()) - 1;
|
783 |
|
784 | // Only consider aborting early if the estimated time to finish the download
|
785 | // is larger than the estimated time until the player runs out of forward buffer
|
786 | if (requestTimeRemaining <= timeUntilRebuffer) {
|
787 | return false;
|
788 | }
|
789 |
|
790 | const switchCandidate = minRebufferMaxBandwidthSelector({
|
791 | master: this.hls_.playlists.master,
|
792 | currentTime,
|
793 | bandwidth: measuredBandwidth,
|
794 | duration: this.duration_(),
|
795 | segmentDuration,
|
796 | timeUntilRebuffer,
|
797 | currentTimeline: this.currentTimeline_,
|
798 | syncController: this.syncController_
|
799 | });
|
800 |
|
801 | if (!switchCandidate) {
|
802 | return;
|
803 | }
|
804 |
|
805 | const rebufferingImpact = requestTimeRemaining - timeUntilRebuffer;
|
806 |
|
807 | const timeSavedBySwitching = rebufferingImpact - switchCandidate.rebufferingImpact;
|
808 |
|
809 | let minimumTimeSaving = 0.5;
|
810 |
|
811 | // If we are already rebuffering, increase the amount of variance we add to the
|
812 | // potential round trip time of the new request so that we are not too aggressive
|
813 | // with switching to a playlist that might save us a fraction of a second.
|
814 | if (timeUntilRebuffer <= TIME_FUDGE_FACTOR) {
|
815 | minimumTimeSaving = 1;
|
816 | }
|
817 |
|
818 | if (!switchCandidate.playlist ||
|
819 | switchCandidate.playlist.uri === this.playlist_.uri ||
|
820 | timeSavedBySwitching < minimumTimeSaving) {
|
821 | return false;
|
822 | }
|
823 |
|
824 | // set the bandwidth to that of the desired playlist being sure to scale by
|
825 | // BANDWIDTH_VARIANCE and add one so the playlist selector does not exclude it
|
826 | this.bandwidth =
|
827 | switchCandidate.playlist.attributes.BANDWIDTH * Config.BANDWIDTH_VARIANCE + 1;
|
828 | this.abort();
|
829 | this.trigger('bandwidthupdate');
|
830 | return true;
|
831 | }
|
832 |
|
833 | /**
|
834 | * XHR `progress` event handler
|
835 | *
|
836 | * @param {Event}
|
837 | * The XHR `progress` event
|
838 | * @param {Object} simpleSegment
|
839 | * A simplified segment object copy
|
840 | * @private
|
841 | */
|
842 | handleProgress_(event, simpleSegment) {
|
843 | if (!this.pendingSegment_ ||
|
844 | simpleSegment.requestId !== this.pendingSegment_.requestId ||
|
845 | this.abortRequestEarly_(simpleSegment.stats)) {
|
846 | return;
|
847 | }
|
848 |
|
849 | this.trigger('progress');
|
850 | }
|
851 |
|
852 | /**
|
853 | * load a specific segment from a request into the buffer
|
854 | *
|
855 | * @private
|
856 | */
|
857 | loadSegment_(segmentInfo) {
|
858 | this.state = 'WAITING';
|
859 | this.pendingSegment_ = segmentInfo;
|
860 | this.trimBackBuffer_(segmentInfo);
|
861 |
|
862 | segmentInfo.abortRequests = mediaSegmentRequest(this.hls_.xhr,
|
863 | this.xhrOptions_,
|
864 | this.decrypter_,
|
865 | this.createSimplifiedSegmentObj_(segmentInfo),
|
866 | // progress callback
|
867 | this.handleProgress_.bind(this),
|
868 | this.segmentRequestFinished_.bind(this));
|
869 | }
|
870 |
|
871 | /**
|
872 | * trim the back buffer so that we don't have too much data
|
873 | * in the source buffer
|
874 | *
|
875 | * @private
|
876 | *
|
877 | * @param {Object} segmentInfo - the current segment
|
878 | */
|
879 | trimBackBuffer_(segmentInfo) {
|
880 | const seekable = this.seekable_();
|
881 | const currentTime = this.currentTime_();
|
882 | let removeToTime = 0;
|
883 |
|
884 | // Chrome has a hard limit of 150MB of
|
885 | // buffer and a very conservative "garbage collector"
|
886 | // We manually clear out the old buffer to ensure
|
887 | // we don't trigger the QuotaExceeded error
|
888 | // on the source buffer during subsequent appends
|
889 |
|
890 | // If we have a seekable range use that as the limit for what can be removed safely
|
891 | // otherwise remove anything older than 30 seconds before the current play head
|
892 | if (seekable.length &&
|
893 | seekable.start(0) > 0 &&
|
894 | seekable.start(0) < currentTime) {
|
895 | removeToTime = seekable.start(0);
|
896 | } else {
|
897 | removeToTime = currentTime - 30;
|
898 | }
|
899 |
|
900 | if (removeToTime > 0) {
|
901 | this.remove(0, removeToTime);
|
902 | }
|
903 | }
|
904 |
|
905 | /**
|
906 | * created a simplified copy of the segment object with just the
|
907 | * information necessary to perform the XHR and decryption
|
908 | *
|
909 | * @private
|
910 | *
|
911 | * @param {Object} segmentInfo - the current segment
|
912 | * @returns {Object} a simplified segment object copy
|
913 | */
|
914 | createSimplifiedSegmentObj_(segmentInfo) {
|
915 | const segment = segmentInfo.segment;
|
916 | const simpleSegment = {
|
917 | resolvedUri: segment.resolvedUri,
|
918 | byterange: segment.byterange,
|
919 | requestId: segmentInfo.requestId
|
920 | };
|
921 |
|
922 | if (segment.key) {
|
923 | // if the media sequence is greater than 2^32, the IV will be incorrect
|
924 | // assuming 10s segments, that would be about 1300 years
|
925 | const iv = segment.key.iv || new Uint32Array([
|
926 | 0, 0, 0, segmentInfo.mediaIndex + segmentInfo.playlist.mediaSequence
|
927 | ]);
|
928 |
|
929 | simpleSegment.key = {
|
930 | resolvedUri: segment.key.resolvedUri,
|
931 | iv
|
932 | };
|
933 | }
|
934 |
|
935 | if (segment.map) {
|
936 | simpleSegment.map = this.initSegment(segment.map);
|
937 | }
|
938 |
|
939 | return simpleSegment;
|
940 | }
|
941 |
|
942 | /**
|
943 | * Handle the callback from the segmentRequest function and set the
|
944 | * associated SegmentLoader state and errors if necessary
|
945 | *
|
946 | * @private
|
947 | */
|
948 | segmentRequestFinished_(error, simpleSegment) {
|
949 | // every request counts as a media request even if it has been aborted
|
950 | // or canceled due to a timeout
|
951 | this.mediaRequests += 1;
|
952 |
|
953 | if (simpleSegment.stats) {
|
954 | this.mediaBytesTransferred += simpleSegment.stats.bytesReceived;
|
955 | this.mediaTransferDuration += simpleSegment.stats.roundTripTime;
|
956 | }
|
957 |
|
958 | // The request was aborted and the SegmentLoader has already been reset
|
959 | if (!this.pendingSegment_) {
|
960 | this.mediaRequestsAborted += 1;
|
961 | return;
|
962 | }
|
963 |
|
964 | // the request was aborted and the SegmentLoader has already started
|
965 | // another request. this can happen when the timeout for an aborted
|
966 | // request triggers due to a limitation in the XHR library
|
967 | // do not count this as any sort of request or we risk double-counting
|
968 | if (simpleSegment.requestId !== this.pendingSegment_.requestId) {
|
969 | return;
|
970 | }
|
971 |
|
972 | // an error occurred from the active pendingSegment_ so reset everything
|
973 | if (error) {
|
974 | this.pendingSegment_ = null;
|
975 | this.state = 'READY';
|
976 |
|
977 | // the requests were aborted just record the aborted stat and exit
|
978 | // this is not a true error condition and nothing corrective needs
|
979 | // to be done
|
980 | if (error.code === REQUEST_ERRORS.ABORTED) {
|
981 | this.mediaRequestsAborted += 1;
|
982 | return;
|
983 | }
|
984 |
|
985 | this.pause();
|
986 |
|
987 | // the error is really just that at least one of the requests timed-out
|
988 | // set the bandwidth to a very low value and trigger an ABR switch to
|
989 | // take emergency action
|
990 | if (error.code === REQUEST_ERRORS.TIMEOUT) {
|
991 | this.mediaRequestsTimedout += 1;
|
992 | this.bandwidth = 1;
|
993 | this.roundTrip = NaN;
|
994 | this.trigger('bandwidthupdate');
|
995 | return;
|
996 | }
|
997 |
|
998 | // if control-flow has arrived here, then the error is real
|
999 | // emit an error event to blacklist the current playlist
|
1000 | this.mediaRequestsErrored += 1;
|
1001 | this.error(error);
|
1002 | this.trigger('error');
|
1003 | return;
|
1004 | }
|
1005 |
|
1006 | // the response was a success so set any bandwidth stats the request
|
1007 | // generated for ABR purposes
|
1008 | this.bandwidth = simpleSegment.stats.bandwidth;
|
1009 | this.roundTrip = simpleSegment.stats.roundTripTime;
|
1010 |
|
1011 | // if this request included an initialization segment, save that data
|
1012 | // to the initSegment cache
|
1013 | if (simpleSegment.map) {
|
1014 | simpleSegment.map = this.initSegment(simpleSegment.map, true);
|
1015 | }
|
1016 |
|
1017 | this.processSegmentResponse_(simpleSegment);
|
1018 | }
|
1019 |
|
1020 | /**
|
1021 | * Move any important data from the simplified segment object
|
1022 | * back to the real segment object for future phases
|
1023 | *
|
1024 | * @private
|
1025 | */
|
1026 | processSegmentResponse_(simpleSegment) {
|
1027 | const segmentInfo = this.pendingSegment_;
|
1028 |
|
1029 | segmentInfo.bytes = simpleSegment.bytes;
|
1030 | if (simpleSegment.map) {
|
1031 | segmentInfo.segment.map.bytes = simpleSegment.map.bytes;
|
1032 | }
|
1033 |
|
1034 | segmentInfo.endOfAllRequests = simpleSegment.endOfAllRequests;
|
1035 | this.handleSegment_();
|
1036 | }
|
1037 |
|
1038 | /**
|
1039 | * append a decrypted segement to the SourceBuffer through a SourceUpdater
|
1040 | *
|
1041 | * @private
|
1042 | */
|
1043 | handleSegment_() {
|
1044 | if (!this.pendingSegment_) {
|
1045 | this.state = 'READY';
|
1046 | return;
|
1047 | }
|
1048 |
|
1049 | this.state = 'APPENDING';
|
1050 |
|
1051 | const segmentInfo = this.pendingSegment_;
|
1052 | const segment = segmentInfo.segment;
|
1053 |
|
1054 | this.syncController_.probeSegmentInfo(segmentInfo);
|
1055 |
|
1056 | if (segmentInfo.isSyncRequest) {
|
1057 | this.trigger('syncinfoupdate');
|
1058 | this.pendingSegment_ = null;
|
1059 | this.state = 'READY';
|
1060 | return;
|
1061 | }
|
1062 |
|
1063 | if (segmentInfo.timestampOffset !== null &&
|
1064 | segmentInfo.timestampOffset !== this.sourceUpdater_.timestampOffset()) {
|
1065 | this.sourceUpdater_.timestampOffset(segmentInfo.timestampOffset);
|
1066 | // fired when a timestamp offset is set in HLS (can also identify discontinuities)
|
1067 | this.trigger('timestampoffset');
|
1068 | }
|
1069 |
|
1070 | // if the media initialization segment is changing, append it
|
1071 | // before the content segment
|
1072 | if (segment.map) {
|
1073 | const initId = initSegmentId(segment.map);
|
1074 |
|
1075 | if (!this.activeInitSegmentId_ ||
|
1076 | this.activeInitSegmentId_ !== initId) {
|
1077 | const initSegment = this.initSegment(segment.map);
|
1078 |
|
1079 | this.sourceUpdater_.appendBuffer(initSegment.bytes, () => {
|
1080 | this.activeInitSegmentId_ = initId;
|
1081 | });
|
1082 | }
|
1083 | }
|
1084 |
|
1085 | segmentInfo.byteLength = segmentInfo.bytes.byteLength;
|
1086 | if (typeof segment.start === 'number' && typeof segment.end === 'number') {
|
1087 | this.mediaSecondsLoaded += segment.end - segment.start;
|
1088 | } else {
|
1089 | this.mediaSecondsLoaded += segment.duration;
|
1090 | }
|
1091 |
|
1092 | this.sourceUpdater_.appendBuffer(segmentInfo.bytes,
|
1093 | this.handleUpdateEnd_.bind(this));
|
1094 | }
|
1095 |
|
1096 | /**
|
1097 | * callback to run when appendBuffer is finished. detects if we are
|
1098 | * in a good state to do things with the data we got, or if we need
|
1099 | * to wait for more
|
1100 | *
|
1101 | * @private
|
1102 | */
|
1103 | handleUpdateEnd_() {
|
1104 | this.logger_('handleUpdateEnd_', 'segmentInfo:', this.pendingSegment_);
|
1105 |
|
1106 | if (!this.pendingSegment_) {
|
1107 | this.state = 'READY';
|
1108 | if (!this.paused()) {
|
1109 | this.monitorBuffer_();
|
1110 | }
|
1111 | return;
|
1112 | }
|
1113 |
|
1114 | const segmentInfo = this.pendingSegment_;
|
1115 | const segment = segmentInfo.segment;
|
1116 | const isWalkingForward = this.mediaIndex !== null;
|
1117 |
|
1118 | this.pendingSegment_ = null;
|
1119 | this.recordThroughput_(segmentInfo);
|
1120 | this.addSegmentMetadataCue_(segmentInfo);
|
1121 |
|
1122 | this.state = 'READY';
|
1123 |
|
1124 | this.mediaIndex = segmentInfo.mediaIndex;
|
1125 | this.fetchAtBuffer_ = true;
|
1126 | this.currentTimeline_ = segmentInfo.timeline;
|
1127 |
|
1128 | // We must update the syncinfo to recalculate the seekable range before
|
1129 | // the following conditional otherwise it may consider this a bad "guess"
|
1130 | // and attempt to resync when the post-update seekable window and live
|
1131 | // point would mean that this was the perfect segment to fetch
|
1132 | this.trigger('syncinfoupdate');
|
1133 |
|
1134 | // If we previously appended a segment that ends more than 3 targetDurations before
|
1135 | // the currentTime_ that means that our conservative guess was too conservative.
|
1136 | // In that case, reset the loader state so that we try to use any information gained
|
1137 | // from the previous request to create a new, more accurate, sync-point.
|
1138 | if (segment.end &&
|
1139 | this.currentTime_() - segment.end > segmentInfo.playlist.targetDuration * 3) {
|
1140 | this.resetEverything();
|
1141 | return;
|
1142 | }
|
1143 |
|
1144 | // Don't do a rendition switch unless we have enough time to get a sync segment
|
1145 | // and conservatively guess
|
1146 | if (isWalkingForward) {
|
1147 | this.trigger('bandwidthupdate');
|
1148 | }
|
1149 | this.trigger('progress');
|
1150 |
|
1151 | // any time an update finishes and the last segment is in the
|
1152 | // buffer, end the stream. this ensures the "ended" event will
|
1153 | // fire if playback reaches that point.
|
1154 | const isEndOfStream = detectEndOfStream(segmentInfo.playlist,
|
1155 | this.mediaSource_,
|
1156 | segmentInfo.mediaIndex + 1);
|
1157 |
|
1158 | if (isEndOfStream) {
|
1159 | this.endOfStream();
|
1160 | }
|
1161 |
|
1162 | if (!this.paused()) {
|
1163 | this.monitorBuffer_();
|
1164 | }
|
1165 | }
|
1166 |
|
1167 | /**
|
1168 | * Records the current throughput of the decrypt, transmux, and append
|
1169 | * portion of the semgment pipeline. `throughput.rate` is a the cumulative
|
1170 | * moving average of the throughput. `throughput.count` is the number of
|
1171 | * data points in the average.
|
1172 | *
|
1173 | * @private
|
1174 | * @param {Object} segmentInfo the object returned by loadSegment
|
1175 | */
|
1176 | recordThroughput_(segmentInfo) {
|
1177 | const rate = this.throughput.rate;
|
1178 | // Add one to the time to ensure that we don't accidentally attempt to divide
|
1179 | // by zero in the case where the throughput is ridiculously high
|
1180 | const segmentProcessingTime =
|
1181 | Date.now() - segmentInfo.endOfAllRequests + 1;
|
1182 | // Multiply by 8000 to convert from bytes/millisecond to bits/second
|
1183 | const segmentProcessingThroughput =
|
1184 | Math.floor((segmentInfo.byteLength / segmentProcessingTime) * 8 * 1000);
|
1185 |
|
1186 | // This is just a cumulative moving average calculation:
|
1187 | // newAvg = oldAvg + (sample - oldAvg) / (sampleCount + 1)
|
1188 | this.throughput.rate +=
|
1189 | (segmentProcessingThroughput - rate) / (++this.throughput.count);
|
1190 | }
|
1191 |
|
1192 | /**
|
1193 | * A debugging logger noop that is set to console.log only if debugging
|
1194 | * is enabled globally
|
1195 | *
|
1196 | * @private
|
1197 | */
|
1198 | logger_() {}
|
1199 |
|
1200 | /**
|
1201 | * Adds a cue to the segment-metadata track with some metadata information about the
|
1202 | * segment
|
1203 | *
|
1204 | * @private
|
1205 | * @param {Object} segmentInfo
|
1206 | * the object returned by loadSegment
|
1207 | * @method addSegmentMetadataCue_
|
1208 | */
|
1209 | addSegmentMetadataCue_(segmentInfo) {
|
1210 | if (!this.segmentMetadataTrack_) {
|
1211 | return;
|
1212 | }
|
1213 |
|
1214 | const segment = segmentInfo.segment;
|
1215 | const start = segment.start;
|
1216 | const end = segment.end;
|
1217 |
|
1218 | // Do not try adding the cue if the start and end times are invalid.
|
1219 | if (!finite(start) || !finite(end)) {
|
1220 | return;
|
1221 | }
|
1222 |
|
1223 | removeCuesFromTrack(start, end, this.segmentMetadataTrack_);
|
1224 |
|
1225 | const Cue = window.WebKitDataCue || window.VTTCue;
|
1226 | const value = {
|
1227 | uri: segmentInfo.uri,
|
1228 | timeline: segmentInfo.timeline,
|
1229 | playlist: segmentInfo.playlist.uri,
|
1230 | start,
|
1231 | end
|
1232 | };
|
1233 | const data = JSON.stringify(value);
|
1234 | const cue = new Cue(start, end, data);
|
1235 |
|
1236 | // Attach the metadata to the value property of the cue to keep consistency between
|
1237 | // the differences of WebKitDataCue in safari and VTTCue in other browsers
|
1238 | cue.value = value;
|
1239 |
|
1240 | this.segmentMetadataTrack_.addCue(cue);
|
1241 | }
|
1242 | }
|