UNPKG

22.3 kBJavaScriptView Raw
1/**
2 * @file playlist.js
3 *
4 * Playlist related utilities.
5 */
6import videojs from 'video.js';
7import window from 'global/window';
8import {isAudioCodec} from '@videojs/vhs-utils/es/codecs.js';
9import {TIME_FUDGE_FACTOR} from './ranges.js';
10
11const {createTimeRange} = videojs;
12
13/**
14 * A function to get a combined list of parts and segments with durations
15 * and indexes.
16 *
17 * @param {Playlist} playlist the playlist to get the list for.
18 *
19 * @return {Array} The part/segment list.
20 */
21export const getPartsAndSegments = (playlist) => (playlist.segments || []).reduce((acc, segment, si) => {
22 if (segment.parts) {
23 segment.parts.forEach(function(part, pi) {
24 acc.push({duration: part.duration, segmentIndex: si, partIndex: pi, part, segment});
25 });
26 } else {
27 acc.push({duration: segment.duration, segmentIndex: si, partIndex: null, segment, part: null});
28 }
29 return acc;
30}, []);
31
32export const getLastParts = (media) => {
33 const lastSegment = media.segments && media.segments.length && media.segments[media.segments.length - 1];
34
35 return lastSegment && lastSegment.parts || [];
36};
37
38export const getKnownPartCount = ({preloadSegment}) => {
39 if (!preloadSegment) {
40 return;
41 }
42 const {parts, preloadHints} = preloadSegment;
43 let partCount = (preloadHints || [])
44 .reduce((count, hint) => count + (hint.type === 'PART' ? 1 : 0), 0);
45
46 partCount += (parts && parts.length) ? parts.length : 0;
47
48 return partCount;
49};
50/**
51 * Get the number of seconds to delay from the end of a
52 * live playlist.
53 *
54 * @param {Playlist} master the master playlist
55 * @param {Playlist} media the media playlist
56 * @return {number} the hold back in seconds.
57 */
58export const liveEdgeDelay = (master, media) => {
59 if (media.endList) {
60 return 0;
61 }
62
63 // dash suggestedPresentationDelay trumps everything
64 if (master && master.suggestedPresentationDelay) {
65 return master.suggestedPresentationDelay;
66 }
67
68 const hasParts = getLastParts(media).length > 0;
69
70 // look for "part" delays from ll-hls first
71 if (hasParts && media.serverControl && media.serverControl.partHoldBack) {
72 return media.serverControl.partHoldBack;
73 } else if (hasParts && media.partTargetDuration) {
74 return media.partTargetDuration * 3;
75
76 // finally look for full segment delays
77 } else if (media.serverControl && media.serverControl.holdBack) {
78 return media.serverControl.holdBack;
79 } else if (media.targetDuration) {
80 return media.targetDuration * 3;
81 }
82
83 return 0;
84};
85
86/**
87 * walk backward until we find a duration we can use
88 * or return a failure
89 *
90 * @param {Playlist} playlist the playlist to walk through
91 * @param {Number} endSequence the mediaSequence to stop walking on
92 */
93
94const backwardDuration = function(playlist, endSequence) {
95 let result = 0;
96 let i = endSequence - playlist.mediaSequence;
97 // if a start time is available for segment immediately following
98 // the interval, use it
99 let segment = playlist.segments[i];
100
101 // Walk backward until we find the latest segment with timeline
102 // information that is earlier than endSequence
103 if (segment) {
104 if (typeof segment.start !== 'undefined') {
105 return { result: segment.start, precise: true };
106 }
107 if (typeof segment.end !== 'undefined') {
108 return {
109 result: segment.end - segment.duration,
110 precise: true
111 };
112 }
113 }
114 while (i--) {
115 segment = playlist.segments[i];
116 if (typeof segment.end !== 'undefined') {
117 return { result: result + segment.end, precise: true };
118 }
119
120 result += segment.duration;
121
122 if (typeof segment.start !== 'undefined') {
123 return { result: result + segment.start, precise: true };
124 }
125 }
126 return { result, precise: false };
127};
128
129/**
130 * walk forward until we find a duration we can use
131 * or return a failure
132 *
133 * @param {Playlist} playlist the playlist to walk through
134 * @param {number} endSequence the mediaSequence to stop walking on
135 */
136const forwardDuration = function(playlist, endSequence) {
137 let result = 0;
138 let segment;
139 let i = endSequence - playlist.mediaSequence;
140 // Walk forward until we find the earliest segment with timeline
141 // information
142
143 for (; i < playlist.segments.length; i++) {
144 segment = playlist.segments[i];
145 if (typeof segment.start !== 'undefined') {
146 return {
147 result: segment.start - result,
148 precise: true
149 };
150 }
151
152 result += segment.duration;
153
154 if (typeof segment.end !== 'undefined') {
155 return {
156 result: segment.end - result,
157 precise: true
158 };
159 }
160
161 }
162 // indicate we didn't find a useful duration estimate
163 return { result: -1, precise: false };
164};
165
166/**
167 * Calculate the media duration from the segments associated with a
168 * playlist. The duration of a subinterval of the available segments
169 * may be calculated by specifying an end index.
170 *
171 * @param {Object} playlist a media playlist object
172 * @param {number=} endSequence an exclusive upper boundary
173 * for the playlist. Defaults to playlist length.
174 * @param {number} expired the amount of time that has dropped
175 * off the front of the playlist in a live scenario
176 * @return {number} the duration between the first available segment
177 * and end index.
178 */
179const intervalDuration = function(playlist, endSequence, expired) {
180 if (typeof endSequence === 'undefined') {
181 endSequence = playlist.mediaSequence + playlist.segments.length;
182 }
183
184 if (endSequence < playlist.mediaSequence) {
185 return 0;
186 }
187
188 // do a backward walk to estimate the duration
189 const backward = backwardDuration(playlist, endSequence);
190
191 if (backward.precise) {
192 // if we were able to base our duration estimate on timing
193 // information provided directly from the Media Source, return
194 // it
195 return backward.result;
196 }
197
198 // walk forward to see if a precise duration estimate can be made
199 // that way
200 const forward = forwardDuration(playlist, endSequence);
201
202 if (forward.precise) {
203 // we found a segment that has been buffered and so it's
204 // position is known precisely
205 return forward.result;
206 }
207
208 // return the less-precise, playlist-based duration estimate
209 return backward.result + expired;
210};
211
212/**
213 * Calculates the duration of a playlist. If a start and end index
214 * are specified, the duration will be for the subset of the media
215 * timeline between those two indices. The total duration for live
216 * playlists is always Infinity.
217 *
218 * @param {Object} playlist a media playlist object
219 * @param {number=} endSequence an exclusive upper
220 * boundary for the playlist. Defaults to the playlist media
221 * sequence number plus its length.
222 * @param {number=} expired the amount of time that has
223 * dropped off the front of the playlist in a live scenario
224 * @return {number} the duration between the start index and end
225 * index.
226 */
227export const duration = function(playlist, endSequence, expired) {
228 if (!playlist) {
229 return 0;
230 }
231
232 if (typeof expired !== 'number') {
233 expired = 0;
234 }
235
236 // if a slice of the total duration is not requested, use
237 // playlist-level duration indicators when they're present
238 if (typeof endSequence === 'undefined') {
239 // if present, use the duration specified in the playlist
240 if (playlist.totalDuration) {
241 return playlist.totalDuration;
242 }
243
244 // duration should be Infinity for live playlists
245 if (!playlist.endList) {
246 return window.Infinity;
247 }
248 }
249
250 // calculate the total duration based on the segment durations
251 return intervalDuration(
252 playlist,
253 endSequence,
254 expired
255 );
256};
257
258/**
259 * Calculate the time between two indexes in the current playlist
260 * neight the start- nor the end-index need to be within the current
261 * playlist in which case, the targetDuration of the playlist is used
262 * to approximate the durations of the segments
263 *
264 * @param {Array} options.durationList list to iterate over for durations.
265 * @param {number} options.defaultDuration duration to use for elements before or after the durationList
266 * @param {number} options.startIndex partsAndSegments index to start
267 * @param {number} options.endIndex partsAndSegments index to end.
268 * @return {number} the number of seconds between startIndex and endIndex
269 */
270export const sumDurations = function({defaultDuration, durationList, startIndex, endIndex}) {
271 let durations = 0;
272
273 if (startIndex > endIndex) {
274 [startIndex, endIndex] = [endIndex, startIndex];
275 }
276
277 if (startIndex < 0) {
278 for (let i = startIndex; i < Math.min(0, endIndex); i++) {
279 durations += defaultDuration;
280 }
281 startIndex = 0;
282 }
283
284 for (let i = startIndex; i < endIndex; i++) {
285 durations += durationList[i].duration;
286 }
287
288 return durations;
289};
290
291/**
292 * Calculates the playlist end time
293 *
294 * @param {Object} playlist a media playlist object
295 * @param {number=} expired the amount of time that has
296 * dropped off the front of the playlist in a live scenario
297 * @param {boolean|false} useSafeLiveEnd a boolean value indicating whether or not the
298 * playlist end calculation should consider the safe live end
299 * (truncate the playlist end by three segments). This is normally
300 * used for calculating the end of the playlist's seekable range.
301 * This takes into account the value of liveEdgePadding.
302 * Setting liveEdgePadding to 0 is equivalent to setting this to false.
303 * @param {number} liveEdgePadding a number indicating how far from the end of the playlist we should be in seconds.
304 * If this is provided, it is used in the safe live end calculation.
305 * Setting useSafeLiveEnd=false or liveEdgePadding=0 are equivalent.
306 * Corresponds to suggestedPresentationDelay in DASH manifests.
307 * @return {number} the end time of playlist
308 * @function playlistEnd
309 */
310export const playlistEnd = function(playlist, expired, useSafeLiveEnd, liveEdgePadding) {
311 if (!playlist || !playlist.segments) {
312 return null;
313 }
314 if (playlist.endList) {
315 return duration(playlist);
316 }
317
318 if (expired === null) {
319 return null;
320 }
321
322 expired = expired || 0;
323
324 let lastSegmentTime = intervalDuration(
325 playlist,
326 playlist.mediaSequence + playlist.segments.length,
327 expired
328 );
329
330 if (useSafeLiveEnd) {
331 liveEdgePadding = typeof liveEdgePadding === 'number' ? liveEdgePadding : liveEdgeDelay(null, playlist);
332 lastSegmentTime -= liveEdgePadding;
333 }
334
335 // don't return a time less than zero
336 return Math.max(0, lastSegmentTime);
337};
338
339/**
340 * Calculates the interval of time that is currently seekable in a
341 * playlist. The returned time ranges are relative to the earliest
342 * moment in the specified playlist that is still available. A full
343 * seekable implementation for live streams would need to offset
344 * these values by the duration of content that has expired from the
345 * stream.
346 *
347 * @param {Object} playlist a media playlist object
348 * dropped off the front of the playlist in a live scenario
349 * @param {number=} expired the amount of time that has
350 * dropped off the front of the playlist in a live scenario
351 * @param {number} liveEdgePadding how far from the end of the playlist we should be in seconds.
352 * Corresponds to suggestedPresentationDelay in DASH manifests.
353 * @return {TimeRanges} the periods of time that are valid targets
354 * for seeking
355 */
356export const seekable = function(playlist, expired, liveEdgePadding) {
357 const useSafeLiveEnd = true;
358 const seekableStart = expired || 0;
359 const seekableEnd = playlistEnd(playlist, expired, useSafeLiveEnd, liveEdgePadding);
360
361 if (seekableEnd === null) {
362 return createTimeRange();
363 }
364 return createTimeRange(seekableStart, seekableEnd);
365};
366
367/**
368 * Determine the index and estimated starting time of the segment that
369 * contains a specified playback position in a media playlist.
370 *
371 * @param {Object} options.playlist the media playlist to query
372 * @param {number} options.currentTime The number of seconds since the earliest
373 * possible position to determine the containing segment for
374 * @param {number} options.startTime the time when the segment/part starts
375 * @param {number} options.startingSegmentIndex the segment index to start looking at.
376 * @param {number?} [options.startingPartIndex] the part index to look at within the segment.
377 *
378 * @return {Object} an object with partIndex, segmentIndex, and startTime.
379 */
380export const getMediaInfoForTime = function({
381 playlist,
382 currentTime,
383 startingSegmentIndex,
384 startingPartIndex,
385 startTime,
386 experimentalExactManifestTimings
387}) {
388
389 let time = currentTime - startTime;
390 const partsAndSegments = getPartsAndSegments(playlist);
391
392 let startIndex = 0;
393
394 for (let i = 0; i < partsAndSegments.length; i++) {
395 const partAndSegment = partsAndSegments[i];
396
397 if (startingSegmentIndex !== partAndSegment.segmentIndex) {
398 continue;
399 }
400
401 // skip this if part index does not match.
402 if (typeof startingPartIndex === 'number' && typeof partAndSegment.partIndex === 'number' && startingPartIndex !== partAndSegment.partIndex) {
403 continue;
404 }
405
406 startIndex = i;
407 break;
408 }
409
410 if (time < 0) {
411 // Walk backward from startIndex in the playlist, adding durations
412 // until we find a segment that contains `time` and return it
413 if (startIndex > 0) {
414 for (let i = startIndex - 1; i >= 0; i--) {
415 const partAndSegment = partsAndSegments[i];
416
417 time += partAndSegment.duration;
418
419 if (experimentalExactManifestTimings) {
420 if (time < 0) {
421 continue;
422 }
423 } else if ((time + TIME_FUDGE_FACTOR) <= 0) {
424 continue;
425 }
426 return {
427 partIndex: partAndSegment.partIndex,
428 segmentIndex: partAndSegment.segmentIndex,
429 startTime: startTime - sumDurations({
430 defaultDuration: playlist.targetDuration,
431 durationList: partsAndSegments,
432 startIndex,
433 endIndex: i
434 })
435 };
436 }
437 }
438
439 // We were unable to find a good segment within the playlist
440 // so select the first segment
441 return {
442 partIndex: partsAndSegments[0] && partsAndSegments[0].partIndex || null,
443 segmentIndex: partsAndSegments[0] && partsAndSegments[0].segmentIndex || 0,
444 startTime: currentTime
445 };
446 }
447
448 // When startIndex is negative, we first walk forward to first segment
449 // adding target durations. If we "run out of time" before getting to
450 // the first segment, return the first segment
451 if (startIndex < 0) {
452 for (let i = startIndex; i < 0; i++) {
453 time -= playlist.targetDuration;
454
455 if (time < 0) {
456 return {
457 partIndex: partsAndSegments[0] && partsAndSegments[0].partIndex || null,
458 segmentIndex: partsAndSegments[0] && partsAndSegments[0].segmentIndex || 0,
459 startTime: currentTime
460 };
461 }
462 }
463 startIndex = 0;
464 }
465
466 // Walk forward from startIndex in the playlist, subtracting durations
467 // until we find a segment that contains `time` and return it
468 for (let i = startIndex; i < partsAndSegments.length; i++) {
469 const partAndSegment = partsAndSegments[i];
470
471 time -= partAndSegment.duration;
472
473 if (experimentalExactManifestTimings) {
474 if (time > 0) {
475 continue;
476 }
477 } else if ((time - TIME_FUDGE_FACTOR) >= 0) {
478 continue;
479 }
480
481 return {
482 partIndex: partAndSegment.partIndex,
483 segmentIndex: partAndSegment.segmentIndex,
484 startTime: startTime + sumDurations({
485 defaultDuration: playlist.targetDuration,
486 durationList: partsAndSegments,
487 startIndex,
488 endIndex: i
489 })
490 };
491 }
492
493 // We are out of possible candidates so load the last one...
494 return {
495 segmentIndex: partsAndSegments[partsAndSegments.length - 1].segmentIndex,
496 partIndex: partsAndSegments[partsAndSegments.length - 1].partIndex,
497 startTime: currentTime
498 };
499};
500
501/**
502 * Check whether the playlist is blacklisted or not.
503 *
504 * @param {Object} playlist the media playlist object
505 * @return {boolean} whether the playlist is blacklisted or not
506 * @function isBlacklisted
507 */
508export const isBlacklisted = function(playlist) {
509 return playlist.excludeUntil && playlist.excludeUntil > Date.now();
510};
511
512/**
513 * Check whether the playlist is compatible with current playback configuration or has
514 * been blacklisted permanently for being incompatible.
515 *
516 * @param {Object} playlist the media playlist object
517 * @return {boolean} whether the playlist is incompatible or not
518 * @function isIncompatible
519 */
520export const isIncompatible = function(playlist) {
521 return playlist.excludeUntil && playlist.excludeUntil === Infinity;
522};
523
524/**
525 * Check whether the playlist is enabled or not.
526 *
527 * @param {Object} playlist the media playlist object
528 * @return {boolean} whether the playlist is enabled or not
529 * @function isEnabled
530 */
531export const isEnabled = function(playlist) {
532 const blacklisted = isBlacklisted(playlist);
533
534 return (!playlist.disabled && !blacklisted);
535};
536
537/**
538 * Check whether the playlist has been manually disabled through the representations api.
539 *
540 * @param {Object} playlist the media playlist object
541 * @return {boolean} whether the playlist is disabled manually or not
542 * @function isDisabled
543 */
544export const isDisabled = function(playlist) {
545 return playlist.disabled;
546};
547
548/**
549 * Returns whether the current playlist is an AES encrypted HLS stream
550 *
551 * @return {boolean} true if it's an AES encrypted HLS stream
552 */
553export const isAes = function(media) {
554 for (let i = 0; i < media.segments.length; i++) {
555 if (media.segments[i].key) {
556 return true;
557 }
558 }
559 return false;
560};
561
562/**
563 * Checks if the playlist has a value for the specified attribute
564 *
565 * @param {string} attr
566 * Attribute to check for
567 * @param {Object} playlist
568 * The media playlist object
569 * @return {boolean}
570 * Whether the playlist contains a value for the attribute or not
571 * @function hasAttribute
572 */
573export const hasAttribute = function(attr, playlist) {
574 return playlist.attributes && playlist.attributes[attr];
575};
576
577/**
578 * Estimates the time required to complete a segment download from the specified playlist
579 *
580 * @param {number} segmentDuration
581 * Duration of requested segment
582 * @param {number} bandwidth
583 * Current measured bandwidth of the player
584 * @param {Object} playlist
585 * The media playlist object
586 * @param {number=} bytesReceived
587 * Number of bytes already received for the request. Defaults to 0
588 * @return {number|NaN}
589 * The estimated time to request the segment. NaN if bandwidth information for
590 * the given playlist is unavailable
591 * @function estimateSegmentRequestTime
592 */
593export const estimateSegmentRequestTime = function(
594 segmentDuration,
595 bandwidth,
596 playlist,
597 bytesReceived = 0
598) {
599 if (!hasAttribute('BANDWIDTH', playlist)) {
600 return NaN;
601 }
602
603 const size = segmentDuration * playlist.attributes.BANDWIDTH;
604
605 return (size - (bytesReceived * 8)) / bandwidth;
606};
607
608/*
609 * Returns whether the current playlist is the lowest rendition
610 *
611 * @return {Boolean} true if on lowest rendition
612 */
613export const isLowestEnabledRendition = (master, media) => {
614 if (master.playlists.length === 1) {
615 return true;
616 }
617
618 const currentBandwidth = media.attributes.BANDWIDTH || Number.MAX_VALUE;
619
620 return (master.playlists.filter((playlist) => {
621 if (!isEnabled(playlist)) {
622 return false;
623 }
624
625 return (playlist.attributes.BANDWIDTH || 0) < currentBandwidth;
626
627 }).length === 0);
628};
629
630export const playlistMatch = (a, b) => {
631 // both playlits are null
632 // or only one playlist is non-null
633 // no match
634 if (!a && !b || (!a && b) || (a && !b)) {
635 return false;
636 }
637
638 // playlist objects are the same, match
639 if (a === b) {
640 return true;
641 }
642
643 // first try to use id as it should be the most
644 // accurate
645 if (a.id && b.id && a.id === b.id) {
646 return true;
647 }
648
649 // next try to use reslovedUri as it should be the
650 // second most accurate.
651 if (a.resolvedUri && b.resolvedUri && a.resolvedUri === b.resolvedUri) {
652 return true;
653 }
654
655 // finally try to use uri as it should be accurate
656 // but might miss a few cases for relative uris
657 if (a.uri && b.uri && a.uri === b.uri) {
658 return true;
659 }
660
661 return false;
662};
663
664const someAudioVariant = function(master, callback) {
665 const AUDIO = master && master.mediaGroups && master.mediaGroups.AUDIO || {};
666 let found = false;
667
668 for (const groupName in AUDIO) {
669 for (const label in AUDIO[groupName]) {
670 found = callback(AUDIO[groupName][label]);
671
672 if (found) {
673 break;
674 }
675 }
676
677 if (found) {
678 break;
679 }
680 }
681
682 return !!found;
683};
684
685export const isAudioOnly = (master) => {
686 // we are audio only if we have no main playlists but do
687 // have media group playlists.
688 if (!master || !master.playlists || !master.playlists.length) {
689 // without audio variants or playlists this
690 // is not an audio only master.
691 const found = someAudioVariant(master, (variant) =>
692 (variant.playlists && variant.playlists.length) || variant.uri);
693
694 return found;
695 }
696
697 // if every playlist has only an audio codec it is audio only
698 for (let i = 0; i < master.playlists.length; i++) {
699 const playlist = master.playlists[i];
700 const CODECS = playlist.attributes && playlist.attributes.CODECS;
701
702 // all codecs are audio, this is an audio playlist.
703 if (CODECS && CODECS.split(',').every((c) => isAudioCodec(c))) {
704 continue;
705 }
706
707 // playlist is in an audio group it is audio only
708 const found = someAudioVariant(master, (variant) => playlistMatch(playlist, variant));
709
710 if (found) {
711 continue;
712 }
713
714 // if we make it here this playlist isn't audio and we
715 // are not audio only
716 return false;
717 }
718
719 // if we make it past every playlist without returning, then
720 // this is an audio only playlist.
721 return true;
722};
723
724// exports
725export default {
726 liveEdgeDelay,
727 duration,
728 seekable,
729 getMediaInfoForTime,
730 isEnabled,
731 isDisabled,
732 isBlacklisted,
733 isIncompatible,
734 playlistEnd,
735 isAes,
736 hasAttribute,
737 estimateSegmentRequestTime,
738 isLowestEnabledRendition,
739 isAudioOnly,
740 playlistMatch
741};