UNPKG

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