UNPKG

20.7 kBJavaScriptView Raw
1/**
2 * @file sync-controller.js
3 */
4
5import {sumDurations, getPartsAndSegments} from './playlist';
6import videojs from 'video.js';
7import logger from './util/logger';
8
9// The maximum gap allowed between two media sequence tags when trying to
10// synchronize expired playlist segments.
11// the max media sequence diff is 48 hours of live stream
12// content with two second segments. Anything larger than that
13// will likely be invalid.
14const MAX_MEDIA_SEQUENCE_DIFF_FOR_SYNC = 86400;
15
16export const syncPointStrategies = [
17 // Stategy "VOD": Handle the VOD-case where the sync-point is *always*
18 // the equivalence display-time 0 === segment-index 0
19 {
20 name: 'VOD',
21 run: (syncController, playlist, duration, currentTimeline, currentTime) => {
22 if (duration !== Infinity) {
23 const syncPoint = {
24 time: 0,
25 segmentIndex: 0,
26 partIndex: null
27 };
28
29 return syncPoint;
30 }
31 return null;
32 }
33 },
34 // Stategy "ProgramDateTime": We have a program-date-time tag in this playlist
35 {
36 name: 'ProgramDateTime',
37 run: (syncController, playlist, duration, currentTimeline, currentTime) => {
38 if (!Object.keys(syncController.timelineToDatetimeMappings).length) {
39 return null;
40 }
41
42 let syncPoint = null;
43 let lastDistance = null;
44 const partsAndSegments = getPartsAndSegments(playlist);
45
46 currentTime = currentTime || 0;
47 for (let i = 0; i < partsAndSegments.length; i++) {
48 // start from the end and loop backwards for live
49 // or start from the front and loop forwards for non-live
50 const index = (playlist.endList || currentTime === 0) ? i : partsAndSegments.length - (i + 1);
51 const partAndSegment = partsAndSegments[index];
52 const segment = partAndSegment.segment;
53 const datetimeMapping =
54 syncController.timelineToDatetimeMappings[segment.timeline];
55
56 if (!datetimeMapping) {
57 continue;
58 }
59
60 if (segment.dateTimeObject) {
61 const segmentTime = segment.dateTimeObject.getTime() / 1000;
62 let start = segmentTime + datetimeMapping;
63
64 // take part duration into account.
65 if (segment.parts && typeof partAndSegment.partIndex === 'number') {
66 for (let z = 0; z < partAndSegment.partIndex; z++) {
67 start += segment.parts[z].duration;
68 }
69 }
70 const distance = Math.abs(currentTime - start);
71
72 // Once the distance begins to increase, or if distance is 0, we have passed
73 // currentTime and can stop looking for better candidates
74 if (lastDistance !== null && (distance === 0 || lastDistance < distance)) {
75 break;
76 }
77
78 lastDistance = distance;
79 syncPoint = {
80 time: start,
81 segmentIndex: partAndSegment.segmentIndex,
82 partIndex: partAndSegment.partIndex
83 };
84 }
85 }
86 return syncPoint;
87 }
88 },
89 // Stategy "Segment": We have a known time mapping for a timeline and a
90 // segment in the current timeline with timing data
91 {
92 name: 'Segment',
93 run: (syncController, playlist, duration, currentTimeline, currentTime) => {
94 let syncPoint = null;
95 let lastDistance = null;
96
97 currentTime = currentTime || 0;
98 const partsAndSegments = getPartsAndSegments(playlist);
99
100 for (let i = 0; i < partsAndSegments.length; i++) {
101 // start from the end and loop backwards for live
102 // or start from the front and loop forwards for non-live
103 const index = (playlist.endList || currentTime === 0) ? i : partsAndSegments.length - (i + 1);
104 const partAndSegment = partsAndSegments[index];
105 const segment = partAndSegment.segment;
106 const start = partAndSegment.part && partAndSegment.part.start || segment && segment.start;
107
108 if (segment.timeline === currentTimeline && typeof start !== 'undefined') {
109 const distance = Math.abs(currentTime - start);
110
111 // Once the distance begins to increase, we have passed
112 // currentTime and can stop looking for better candidates
113 if (lastDistance !== null && lastDistance < distance) {
114 break;
115 }
116
117 if (!syncPoint || lastDistance === null || lastDistance >= distance) {
118 lastDistance = distance;
119 syncPoint = {
120 time: start,
121 segmentIndex: partAndSegment.segmentIndex,
122 partIndex: partAndSegment.partIndex
123 };
124 }
125
126 }
127 }
128 return syncPoint;
129 }
130 },
131 // Stategy "Discontinuity": We have a discontinuity with a known
132 // display-time
133 {
134 name: 'Discontinuity',
135 run: (syncController, playlist, duration, currentTimeline, currentTime) => {
136 let syncPoint = null;
137
138 currentTime = currentTime || 0;
139
140 if (playlist.discontinuityStarts && playlist.discontinuityStarts.length) {
141 let lastDistance = null;
142
143 for (let i = 0; i < playlist.discontinuityStarts.length; i++) {
144 const segmentIndex = playlist.discontinuityStarts[i];
145 const discontinuity = playlist.discontinuitySequence + i + 1;
146 const discontinuitySync = syncController.discontinuities[discontinuity];
147
148 if (discontinuitySync) {
149 const distance = Math.abs(currentTime - discontinuitySync.time);
150
151 // Once the distance begins to increase, we have passed
152 // currentTime and can stop looking for better candidates
153 if (lastDistance !== null && lastDistance < distance) {
154 break;
155 }
156
157 if (!syncPoint || lastDistance === null || lastDistance >= distance) {
158 lastDistance = distance;
159 syncPoint = {
160 time: discontinuitySync.time,
161 segmentIndex,
162 partIndex: null
163 };
164 }
165 }
166 }
167 }
168 return syncPoint;
169 }
170 },
171 // Stategy "Playlist": We have a playlist with a known mapping of
172 // segment index to display time
173 {
174 name: 'Playlist',
175 run: (syncController, playlist, duration, currentTimeline, currentTime) => {
176 if (playlist.syncInfo) {
177 const syncPoint = {
178 time: playlist.syncInfo.time,
179 segmentIndex: playlist.syncInfo.mediaSequence - playlist.mediaSequence,
180 partIndex: null
181 };
182
183 return syncPoint;
184 }
185 return null;
186 }
187 }
188];
189
190export default class SyncController extends videojs.EventTarget {
191 constructor(options = {}) {
192 super();
193 // ...for synching across variants
194 this.timelines = [];
195 this.discontinuities = [];
196 this.timelineToDatetimeMappings = {};
197
198 this.logger_ = logger('SyncController');
199 }
200
201 /**
202 * Find a sync-point for the playlist specified
203 *
204 * A sync-point is defined as a known mapping from display-time to
205 * a segment-index in the current playlist.
206 *
207 * @param {Playlist} playlist
208 * The playlist that needs a sync-point
209 * @param {number} duration
210 * Duration of the MediaSource (Infinite if playing a live source)
211 * @param {number} currentTimeline
212 * The last timeline from which a segment was loaded
213 * @return {Object}
214 * A sync-point object
215 */
216 getSyncPoint(playlist, duration, currentTimeline, currentTime) {
217 const syncPoints = this.runStrategies_(
218 playlist,
219 duration,
220 currentTimeline,
221 currentTime
222 );
223
224 if (!syncPoints.length) {
225 // Signal that we need to attempt to get a sync-point manually
226 // by fetching a segment in the playlist and constructing
227 // a sync-point from that information
228 return null;
229 }
230
231 // Now find the sync-point that is closest to the currentTime because
232 // that should result in the most accurate guess about which segment
233 // to fetch
234 return this.selectSyncPoint_(syncPoints, { key: 'time', value: currentTime });
235 }
236
237 /**
238 * Calculate the amount of time that has expired off the playlist during playback
239 *
240 * @param {Playlist} playlist
241 * Playlist object to calculate expired from
242 * @param {number} duration
243 * Duration of the MediaSource (Infinity if playling a live source)
244 * @return {number|null}
245 * The amount of time that has expired off the playlist during playback. Null
246 * if no sync-points for the playlist can be found.
247 */
248 getExpiredTime(playlist, duration) {
249 if (!playlist || !playlist.segments) {
250 return null;
251 }
252
253 const syncPoints = this.runStrategies_(
254 playlist,
255 duration,
256 playlist.discontinuitySequence,
257 0
258 );
259
260 // Without sync-points, there is not enough information to determine the expired time
261 if (!syncPoints.length) {
262 return null;
263 }
264
265 const syncPoint = this.selectSyncPoint_(syncPoints, {
266 key: 'segmentIndex',
267 value: 0
268 });
269
270 // If the sync-point is beyond the start of the playlist, we want to subtract the
271 // duration from index 0 to syncPoint.segmentIndex instead of adding.
272 if (syncPoint.segmentIndex > 0) {
273 syncPoint.time *= -1;
274 }
275
276 return Math.abs(syncPoint.time + sumDurations({
277 defaultDuration: playlist.targetDuration,
278 durationList: playlist.segments,
279 startIndex: syncPoint.segmentIndex,
280 endIndex: 0
281 }));
282 }
283
284 /**
285 * Runs each sync-point strategy and returns a list of sync-points returned by the
286 * strategies
287 *
288 * @private
289 * @param {Playlist} playlist
290 * The playlist that needs a sync-point
291 * @param {number} duration
292 * Duration of the MediaSource (Infinity if playing a live source)
293 * @param {number} currentTimeline
294 * The last timeline from which a segment was loaded
295 * @return {Array}
296 * A list of sync-point objects
297 */
298 runStrategies_(playlist, duration, currentTimeline, currentTime) {
299 const syncPoints = [];
300
301 // Try to find a sync-point in by utilizing various strategies...
302 for (let i = 0; i < syncPointStrategies.length; i++) {
303 const strategy = syncPointStrategies[i];
304 const syncPoint = strategy.run(
305 this,
306 playlist,
307 duration,
308 currentTimeline,
309 currentTime
310 );
311
312 if (syncPoint) {
313 syncPoint.strategy = strategy.name;
314 syncPoints.push({
315 strategy: strategy.name,
316 syncPoint
317 });
318 }
319 }
320
321 return syncPoints;
322 }
323
324 /**
325 * Selects the sync-point nearest the specified target
326 *
327 * @private
328 * @param {Array} syncPoints
329 * List of sync-points to select from
330 * @param {Object} target
331 * Object specifying the property and value we are targeting
332 * @param {string} target.key
333 * Specifies the property to target. Must be either 'time' or 'segmentIndex'
334 * @param {number} target.value
335 * The value to target for the specified key.
336 * @return {Object}
337 * The sync-point nearest the target
338 */
339 selectSyncPoint_(syncPoints, target) {
340 let bestSyncPoint = syncPoints[0].syncPoint;
341 let bestDistance = Math.abs(syncPoints[0].syncPoint[target.key] - target.value);
342 let bestStrategy = syncPoints[0].strategy;
343
344 for (let i = 1; i < syncPoints.length; i++) {
345 const newDistance = Math.abs(syncPoints[i].syncPoint[target.key] - target.value);
346
347 if (newDistance < bestDistance) {
348 bestDistance = newDistance;
349 bestSyncPoint = syncPoints[i].syncPoint;
350 bestStrategy = syncPoints[i].strategy;
351 }
352 }
353
354 this.logger_(`syncPoint for [${target.key}: ${target.value}] chosen with strategy` +
355 ` [${bestStrategy}]: [time:${bestSyncPoint.time},` +
356 ` segmentIndex:${bestSyncPoint.segmentIndex}` +
357 (typeof bestSyncPoint.partIndex === 'number' ? `,partIndex:${bestSyncPoint.partIndex}` : '') +
358 ']');
359
360 return bestSyncPoint;
361 }
362
363 /**
364 * Save any meta-data present on the segments when segments leave
365 * the live window to the playlist to allow for synchronization at the
366 * playlist level later.
367 *
368 * @param {Playlist} oldPlaylist - The previous active playlist
369 * @param {Playlist} newPlaylist - The updated and most current playlist
370 */
371 saveExpiredSegmentInfo(oldPlaylist, newPlaylist) {
372 const mediaSequenceDiff = newPlaylist.mediaSequence - oldPlaylist.mediaSequence;
373
374 // Ignore large media sequence gaps
375 if (mediaSequenceDiff > MAX_MEDIA_SEQUENCE_DIFF_FOR_SYNC) {
376 videojs.log.warn(`Not saving expired segment info. Media sequence gap ${mediaSequenceDiff} is too large.`);
377 return;
378 }
379
380 // When a segment expires from the playlist and it has a start time
381 // save that information as a possible sync-point reference in future
382 for (let i = mediaSequenceDiff - 1; i >= 0; i--) {
383 const lastRemovedSegment = oldPlaylist.segments[i];
384
385 if (lastRemovedSegment && typeof lastRemovedSegment.start !== 'undefined') {
386 newPlaylist.syncInfo = {
387 mediaSequence: oldPlaylist.mediaSequence + i,
388 time: lastRemovedSegment.start
389 };
390 this.logger_(`playlist refresh sync: [time:${newPlaylist.syncInfo.time},` +
391 ` mediaSequence: ${newPlaylist.syncInfo.mediaSequence}]`);
392 this.trigger('syncinfoupdate');
393 break;
394 }
395 }
396 }
397
398 /**
399 * Save the mapping from playlist's ProgramDateTime to display. This should only happen
400 * before segments start to load.
401 *
402 * @param {Playlist} playlist - The currently active playlist
403 */
404 setDateTimeMappingForStart(playlist) {
405 // It's possible for the playlist to be updated before playback starts, meaning time
406 // zero is not yet set. If, during these playlist refreshes, a discontinuity is
407 // crossed, then the old time zero mapping (for the prior timeline) would be retained
408 // unless the mappings are cleared.
409 this.timelineToDatetimeMappings = {};
410
411 if (playlist.segments &&
412 playlist.segments.length &&
413 playlist.segments[0].dateTimeObject) {
414 const firstSegment = playlist.segments[0];
415 const playlistTimestamp = firstSegment.dateTimeObject.getTime() / 1000;
416
417 this.timelineToDatetimeMappings[firstSegment.timeline] = -playlistTimestamp;
418 }
419 }
420
421 /**
422 * Calculates and saves timeline mappings, playlist sync info, and segment timing values
423 * based on the latest timing information.
424 *
425 * @param {Object} options
426 * Options object
427 * @param {SegmentInfo} options.segmentInfo
428 * The current active request information
429 * @param {boolean} options.shouldSaveTimelineMapping
430 * If there's a timeline change, determines if the timeline mapping should be
431 * saved for timeline mapping and program date time mappings.
432 */
433 saveSegmentTimingInfo({ segmentInfo, shouldSaveTimelineMapping }) {
434 const didCalculateSegmentTimeMapping = this.calculateSegmentTimeMapping_(
435 segmentInfo,
436 segmentInfo.timingInfo,
437 shouldSaveTimelineMapping
438 );
439 const segment = segmentInfo.segment;
440
441 if (didCalculateSegmentTimeMapping) {
442 this.saveDiscontinuitySyncInfo_(segmentInfo);
443
444 // If the playlist does not have sync information yet, record that information
445 // now with segment timing information
446 if (!segmentInfo.playlist.syncInfo) {
447 segmentInfo.playlist.syncInfo = {
448 mediaSequence: segmentInfo.playlist.mediaSequence + segmentInfo.mediaIndex,
449 time: segment.start
450 };
451 }
452 }
453
454 const dateTime = segment.dateTimeObject;
455
456 if (segment.discontinuity && shouldSaveTimelineMapping && dateTime) {
457 this.timelineToDatetimeMappings[segment.timeline] = -(dateTime.getTime() / 1000);
458 }
459 }
460
461 timestampOffsetForTimeline(timeline) {
462 if (typeof this.timelines[timeline] === 'undefined') {
463 return null;
464 }
465 return this.timelines[timeline].time;
466 }
467
468 mappingForTimeline(timeline) {
469 if (typeof this.timelines[timeline] === 'undefined') {
470 return null;
471 }
472 return this.timelines[timeline].mapping;
473 }
474
475 /**
476 * Use the "media time" for a segment to generate a mapping to "display time" and
477 * save that display time to the segment.
478 *
479 * @private
480 * @param {SegmentInfo} segmentInfo
481 * The current active request information
482 * @param {Object} timingInfo
483 * The start and end time of the current segment in "media time"
484 * @param {boolean} shouldSaveTimelineMapping
485 * If there's a timeline change, determines if the timeline mapping should be
486 * saved in timelines.
487 * @return {boolean}
488 * Returns false if segment time mapping could not be calculated
489 */
490 calculateSegmentTimeMapping_(segmentInfo, timingInfo, shouldSaveTimelineMapping) {
491 // TODO: remove side effects
492 const segment = segmentInfo.segment;
493 const part = segmentInfo.part;
494 let mappingObj = this.timelines[segmentInfo.timeline];
495 let start;
496 let end;
497
498 if (typeof segmentInfo.timestampOffset === 'number') {
499 mappingObj = {
500 time: segmentInfo.startOfSegment,
501 mapping: segmentInfo.startOfSegment - timingInfo.start
502 };
503 if (shouldSaveTimelineMapping) {
504 this.timelines[segmentInfo.timeline] = mappingObj;
505 this.trigger('timestampoffset');
506
507 this.logger_(`time mapping for timeline ${segmentInfo.timeline}: ` +
508 `[time: ${mappingObj.time}] [mapping: ${mappingObj.mapping}]`);
509 }
510
511 start = segmentInfo.startOfSegment;
512 end = timingInfo.end + mappingObj.mapping;
513
514 } else if (mappingObj) {
515 start = timingInfo.start + mappingObj.mapping;
516 end = timingInfo.end + mappingObj.mapping;
517 } else {
518 return false;
519 }
520
521 if (part) {
522 part.start = start;
523 part.end = end;
524 }
525
526 // If we don't have a segment start yet or the start value we got
527 // is less than our current segment.start value, save a new start value.
528 // We have to do this because parts will have segment timing info saved
529 // multiple times and we want segment start to be the earliest part start
530 // value for that segment.
531 if (!segment.start || start < segment.start) {
532 segment.start = start;
533 }
534 segment.end = end;
535
536 return true;
537 }
538
539 /**
540 * Each time we have discontinuity in the playlist, attempt to calculate the location
541 * in display of the start of the discontinuity and save that. We also save an accuracy
542 * value so that we save values with the most accuracy (closest to 0.)
543 *
544 * @private
545 * @param {SegmentInfo} segmentInfo - The current active request information
546 */
547 saveDiscontinuitySyncInfo_(segmentInfo) {
548 const playlist = segmentInfo.playlist;
549 const segment = segmentInfo.segment;
550
551 // If the current segment is a discontinuity then we know exactly where
552 // the start of the range and it's accuracy is 0 (greater accuracy values
553 // mean more approximation)
554 if (segment.discontinuity) {
555 this.discontinuities[segment.timeline] = {
556 time: segment.start,
557 accuracy: 0
558 };
559 } else if (playlist.discontinuityStarts && playlist.discontinuityStarts.length) {
560 // Search for future discontinuities that we can provide better timing
561 // information for and save that information for sync purposes
562 for (let i = 0; i < playlist.discontinuityStarts.length; i++) {
563 const segmentIndex = playlist.discontinuityStarts[i];
564 const discontinuity = playlist.discontinuitySequence + i + 1;
565 const mediaIndexDiff = segmentIndex - segmentInfo.mediaIndex;
566 const accuracy = Math.abs(mediaIndexDiff);
567
568 if (!this.discontinuities[discontinuity] ||
569 this.discontinuities[discontinuity].accuracy > accuracy) {
570 let time;
571
572 if (mediaIndexDiff < 0) {
573 time = segment.start - sumDurations({
574 defaultDuration: playlist.targetDuration,
575 durationList: playlist.segments,
576 startIndex: segmentInfo.mediaIndex,
577 endIndex: segmentIndex
578 });
579 } else {
580 time = segment.end + sumDurations({
581 defaultDuration: playlist.targetDuration,
582 durationList: playlist.segments,
583 startIndex: segmentInfo.mediaIndex + 1,
584 endIndex: segmentIndex
585 });
586 }
587
588 this.discontinuities[discontinuity] = {
589 time,
590 accuracy
591 };
592 }
593 }
594 }
595 }
596
597 dispose() {
598 this.trigger('dispose');
599 this.off();
600 }
601}