UNPKG

@videojs/http-streaming

Version:

Play back HLS and DASH with Video.js, even where it's not natively supported

1,643 lines (1,397 loc) 628 kB
/** * @videojs/http-streaming * @version 1.13.3 * @copyright 2020 Brightcove, Inc * @license Apache-2.0 */ import URLToolkit from 'url-toolkit'; import window$1 from 'global/window'; import videojs from 'video.js'; import { Parser } from 'm3u8-parser'; import document from 'global/document'; import { parse, parseUTCTiming } from 'mpd-parser'; import mp4Inspector from 'mux.js/lib/tools/mp4-inspector'; import mp4probe from 'mux.js/lib/mp4/probe'; import CaptionParser from 'mux.js/lib/mp4/caption-parser'; import tsInspector from 'mux.js/lib/tools/ts-inspector.js'; import { Decrypter, AsyncStream, decrypt } from 'aes-decrypter'; /** * @file resolve-url.js - Handling how URLs are resolved and manipulated */ var resolveUrl = function resolveUrl(baseURL, relativeURL) { // return early if we don't need to resolve if (/^[a-z]+:/i.test(relativeURL)) { return relativeURL; } // if the base URL is relative then combine with the current location if (!/\/\//i.test(baseURL)) { baseURL = URLToolkit.buildAbsoluteURL(window$1.location.href, baseURL); } return URLToolkit.buildAbsoluteURL(baseURL, relativeURL); }; /** * Checks whether xhr request was redirected and returns correct url depending * on `handleManifestRedirects` option * * @api private * * @param {String} url - an url being requested * @param {XMLHttpRequest} req - xhr request result * * @return {String} */ var resolveManifestRedirect = function resolveManifestRedirect(handleManifestRedirect, url, req) { // To understand how the responseURL below is set and generated: // - https://fetch.spec.whatwg.org/#concept-response-url // - https://fetch.spec.whatwg.org/#atomic-http-redirect-handling if (handleManifestRedirect && req.responseURL && url !== req.responseURL) { return req.responseURL; } return url; }; var classCallCheck = function (instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }; var createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); var get = function get(object, property, receiver) { if (object === null) object = Function.prototype; var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { return get(parent, property, receiver); } } else if ("value" in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } }; var inherits = function (subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }; var possibleConstructorReturn = function (self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; }; var slicedToArray = function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"]) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } }; }(); /** * @file playlist-loader.js * * A state machine that manages the loading, caching, and updating of * M3U8 playlists. * */ var mergeOptions = videojs.mergeOptions, EventTarget = videojs.EventTarget, log = videojs.log; /** * Loops through all supported media groups in master and calls the provided * callback for each group * * @param {Object} master * The parsed master manifest object * @param {Function} callback * Callback to call for each media group */ var forEachMediaGroup = function forEachMediaGroup(master, callback) { ['AUDIO', 'SUBTITLES'].forEach(function (mediaType) { for (var groupKey in master.mediaGroups[mediaType]) { for (var labelKey in master.mediaGroups[mediaType][groupKey]) { var mediaProperties = master.mediaGroups[mediaType][groupKey][labelKey]; callback(mediaProperties, mediaType, groupKey, labelKey); } } }); }; /** * Returns a new array of segments that is the result of merging * properties from an older list of segments onto an updated * list. No properties on the updated playlist will be overridden. * * @param {Array} original the outdated list of segments * @param {Array} update the updated list of segments * @param {Number=} offset the index of the first update * segment in the original segment list. For non-live playlists, * this should always be zero and does not need to be * specified. For live playlists, it should be the difference * between the media sequence numbers in the original and updated * playlists. * @return a list of merged segment objects */ var updateSegments = function updateSegments(original, update, offset) { var result = update.slice(); offset = offset || 0; var length = Math.min(original.length, update.length + offset); for (var i = offset; i < length; i++) { result[i - offset] = mergeOptions(original[i], result[i - offset]); } return result; }; var resolveSegmentUris = function resolveSegmentUris(segment, baseUri) { if (!segment.resolvedUri) { segment.resolvedUri = resolveUrl(baseUri, segment.uri); } if (segment.key && !segment.key.resolvedUri) { segment.key.resolvedUri = resolveUrl(baseUri, segment.key.uri); } if (segment.map && !segment.map.resolvedUri) { segment.map.resolvedUri = resolveUrl(baseUri, segment.map.uri); } }; /** * Returns a new master playlist that is the result of merging an * updated media playlist into the original version. If the * updated media playlist does not match any of the playlist * entries in the original master playlist, null is returned. * * @param {Object} master a parsed master M3U8 object * @param {Object} media a parsed media M3U8 object * @return {Object} a new object that represents the original * master playlist with the updated media playlist merged in, or * null if the merge produced no change. */ var updateMaster = function updateMaster(master, media) { var result = mergeOptions(master, {}); var playlist = result.playlists[media.id]; if (!playlist) { return null; } // consider the playlist unchanged if the number of segments is equal, the media // sequence number is unchanged, and this playlist hasn't become the end of the playlist if (playlist.segments && media.segments && playlist.segments.length === media.segments.length && playlist.endList === media.endList && playlist.mediaSequence === media.mediaSequence) { return null; } var mergedPlaylist = mergeOptions(playlist, media); // if the update could overlap existing segment information, merge the two segment lists if (playlist.segments) { mergedPlaylist.segments = updateSegments(playlist.segments, media.segments, media.mediaSequence - playlist.mediaSequence); } // resolve any segment URIs to prevent us from having to do it later mergedPlaylist.segments.forEach(function (segment) { resolveSegmentUris(segment, mergedPlaylist.resolvedUri); }); // TODO Right now in the playlists array there are two references to each playlist, one // that is referenced by index, and one by URI. The index reference may no longer be // necessary. for (var i = 0; i < result.playlists.length; i++) { if (result.playlists[i].id === media.id) { result.playlists[i] = mergedPlaylist; } } result.playlists[media.id] = mergedPlaylist; // URI reference added for backwards compatibility result.playlists[media.uri] = mergedPlaylist; return result; }; var createPlaylistID = function createPlaylistID(index, uri) { return index + '-' + uri; }; var setupMediaPlaylists = function setupMediaPlaylists(master) { // setup by-URI lookups and resolve media playlist URIs var i = master.playlists.length; while (i--) { var playlist = master.playlists[i]; playlist.resolvedUri = resolveUrl(master.uri, playlist.uri); playlist.id = createPlaylistID(i, playlist.uri); master.playlists[playlist.id] = playlist; // URI reference added for backwards compatibility master.playlists[playlist.uri] = playlist; if (!playlist.attributes) { // Although the spec states an #EXT-X-STREAM-INF tag MUST have a // BANDWIDTH attribute, we can play the stream without it. This means a poorly // formatted master playlist may not have an attribute list. An attributes // property is added here to prevent undefined references when we encounter // this scenario. playlist.attributes = {}; log.warn('Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.'); } } }; var resolveMediaGroupUris = function resolveMediaGroupUris(master) { forEachMediaGroup(master, function (properties) { if (properties.uri) { properties.resolvedUri = resolveUrl(master.uri, properties.uri); } }); }; /** * Calculates the time to wait before refreshing a live playlist * * @param {Object} media * The current media * @param {Boolean} update * True if there were any updates from the last refresh, false otherwise * @return {Number} * The time in ms to wait before refreshing the live playlist */ var refreshDelay = function refreshDelay(media, update) { var lastSegment = media.segments[media.segments.length - 1]; var delay = void 0; if (update && lastSegment && lastSegment.duration) { delay = lastSegment.duration * 1000; } else { // if the playlist is unchanged since the last reload or last segment duration // cannot be determined, try again after half the target duration delay = (media.targetDuration || 10) * 500; } return delay; }; /** * Load a playlist from a remote location * * @class PlaylistLoader * @extends Stream * @param {String} srcUrl the url to start with * @param {Boolean} withCredentials the withCredentials xhr option * @constructor */ var PlaylistLoader = function (_EventTarget) { inherits(PlaylistLoader, _EventTarget); function PlaylistLoader(srcUrl, hls) { var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; classCallCheck(this, PlaylistLoader); var _this = possibleConstructorReturn(this, (PlaylistLoader.__proto__ || Object.getPrototypeOf(PlaylistLoader)).call(this)); var _options$withCredenti = options.withCredentials, withCredentials = _options$withCredenti === undefined ? false : _options$withCredenti, _options$handleManife = options.handleManifestRedirects, handleManifestRedirects = _options$handleManife === undefined ? false : _options$handleManife; _this.srcUrl = srcUrl; _this.hls_ = hls; _this.withCredentials = withCredentials; _this.handleManifestRedirects = handleManifestRedirects; var hlsOptions = hls.options_; _this.customTagParsers = hlsOptions && hlsOptions.customTagParsers || []; _this.customTagMappers = hlsOptions && hlsOptions.customTagMappers || []; if (!_this.srcUrl) { throw new Error('A non-empty playlist URL is required'); } // initialize the loader state _this.state = 'HAVE_NOTHING'; // live playlist staleness timeout _this.on('mediaupdatetimeout', function () { if (_this.state !== 'HAVE_METADATA') { // only refresh the media playlist if no other activity is going on return; } _this.state = 'HAVE_CURRENT_METADATA'; _this.request = _this.hls_.xhr({ uri: resolveUrl(_this.master.uri, _this.media().uri), withCredentials: _this.withCredentials }, function (error, req) { // disposed if (!_this.request) { return; } if (error) { return _this.playlistRequestError(_this.request, _this.media(), 'HAVE_METADATA'); } _this.haveMetadata(_this.request, _this.media().uri, _this.media().id); }); }); return _this; } createClass(PlaylistLoader, [{ key: 'playlistRequestError', value: function playlistRequestError(xhr, playlist, startingState) { var uri = playlist.uri, id = playlist.id; // any in-flight request is now finished this.request = null; if (startingState) { this.state = startingState; } this.error = { playlist: this.master.playlists[id], status: xhr.status, message: 'HLS playlist request error at URL: ' + uri + '.', responseText: xhr.responseText, code: xhr.status >= 500 ? 4 : 2 }; this.trigger('error'); } // update the playlist loader's state in response to a new or // updated playlist. }, { key: 'haveMetadata', value: function haveMetadata(xhr, url, id) { var _this2 = this; // any in-flight request is now finished this.request = null; this.state = 'HAVE_METADATA'; var parser = new Parser(); // adding custom tag parsers this.customTagParsers.forEach(function (customParser) { return parser.addParser(customParser); }); // adding custom tag mappers this.customTagMappers.forEach(function (mapper) { return parser.addTagMapper(mapper); }); parser.push(xhr.responseText); parser.end(); parser.manifest.uri = url; parser.manifest.id = id; // m3u8-parser does not attach an attributes property to media playlists so make // sure that the property is attached to avoid undefined reference errors parser.manifest.attributes = parser.manifest.attributes || {}; // merge this playlist into the master var update = updateMaster(this.master, parser.manifest); this.targetDuration = parser.manifest.targetDuration; if (update) { this.master = update; this.media_ = this.master.playlists[id]; } else { this.trigger('playlistunchanged'); } // refresh live playlists after a target duration passes if (!this.media().endList) { window$1.clearTimeout(this.mediaUpdateTimeout); this.mediaUpdateTimeout = window$1.setTimeout(function () { _this2.trigger('mediaupdatetimeout'); }, refreshDelay(this.media(), !!update)); } this.trigger('loadedplaylist'); } /** * Abort any outstanding work and clean up. */ }, { key: 'dispose', value: function dispose() { this.trigger('dispose'); this.stopRequest(); window$1.clearTimeout(this.mediaUpdateTimeout); window$1.clearTimeout(this.finalRenditionTimeout); this.off(); } }, { key: 'stopRequest', value: function stopRequest() { if (this.request) { var oldRequest = this.request; this.request = null; oldRequest.onreadystatechange = null; oldRequest.abort(); } } /** * When called without any arguments, returns the currently * active media playlist. When called with a single argument, * triggers the playlist loader to asynchronously switch to the * specified media playlist. Calling this method while the * loader is in the HAVE_NOTHING causes an error to be emitted * but otherwise has no effect. * * @param {Object=} playlist the parsed media playlist * object to switch to * @param {Boolean=} is this the last available playlist * * @return {Playlist} the current loaded media */ }, { key: 'media', value: function media(playlist, isFinalRendition) { var _this3 = this; // getter if (!playlist) { return this.media_; } // setter if (this.state === 'HAVE_NOTHING') { throw new Error('Cannot switch media playlist from ' + this.state); } // find the playlist object if the target playlist has been // specified by URI if (typeof playlist === 'string') { if (!this.master.playlists[playlist]) { throw new Error('Unknown playlist URI: ' + playlist); } playlist = this.master.playlists[playlist]; } window$1.clearTimeout(this.finalRenditionTimeout); if (isFinalRendition) { var delay = playlist.targetDuration / 2 * 1000 || 5 * 1000; this.finalRenditionTimeout = window$1.setTimeout(this.media.bind(this, playlist, false), delay); return; } var startingState = this.state; var mediaChange = !this.media_ || playlist.id !== this.media_.id; // switch to fully loaded playlists immediately if (this.master.playlists[playlist.id].endList) { // abort outstanding playlist requests if (this.request) { this.request.onreadystatechange = null; this.request.abort(); this.request = null; } this.state = 'HAVE_METADATA'; this.media_ = playlist; // trigger media change if the active media has been updated if (mediaChange) { this.trigger('mediachanging'); this.trigger('mediachange'); } return; } // switching to the active playlist is a no-op if (!mediaChange) { return; } this.state = 'SWITCHING_MEDIA'; // there is already an outstanding playlist request if (this.request) { if (playlist.resolvedUri === this.request.url) { // requesting to switch to the same playlist multiple times // has no effect after the first return; } this.request.onreadystatechange = null; this.request.abort(); this.request = null; } // request the new playlist if (this.media_) { this.trigger('mediachanging'); } this.request = this.hls_.xhr({ uri: playlist.resolvedUri, withCredentials: this.withCredentials }, function (error, req) { // disposed if (!_this3.request) { return; } playlist.resolvedUri = resolveManifestRedirect(_this3.handleManifestRedirects, playlist.resolvedUri, req); if (error) { return _this3.playlistRequestError(_this3.request, playlist, startingState); } _this3.haveMetadata(req, playlist.uri, playlist.id); // fire loadedmetadata the first time a media playlist is loaded if (startingState === 'HAVE_MASTER') { _this3.trigger('loadedmetadata'); } else { _this3.trigger('mediachange'); } }); } /** * pause loading of the playlist */ }, { key: 'pause', value: function pause() { this.stopRequest(); window$1.clearTimeout(this.mediaUpdateTimeout); if (this.state === 'HAVE_NOTHING') { // If we pause the loader before any data has been retrieved, its as if we never // started, so reset to an unstarted state. this.started = false; } // Need to restore state now that no activity is happening if (this.state === 'SWITCHING_MEDIA') { // if the loader was in the process of switching media, it should either return to // HAVE_MASTER or HAVE_METADATA depending on if the loader has loaded a media // playlist yet. This is determined by the existence of loader.media_ if (this.media_) { this.state = 'HAVE_METADATA'; } else { this.state = 'HAVE_MASTER'; } } else if (this.state === 'HAVE_CURRENT_METADATA') { this.state = 'HAVE_METADATA'; } } /** * start loading of the playlist */ }, { key: 'load', value: function load(isFinalRendition) { var _this4 = this; window$1.clearTimeout(this.mediaUpdateTimeout); var media = this.media(); if (isFinalRendition) { var delay = media ? media.targetDuration / 2 * 1000 : 5 * 1000; this.mediaUpdateTimeout = window$1.setTimeout(function () { return _this4.load(); }, delay); return; } if (!this.started) { this.start(); return; } if (media && !media.endList) { this.trigger('mediaupdatetimeout'); } else { this.trigger('loadedplaylist'); } } /** * start loading of the playlist */ }, { key: 'start', value: function start() { var _this5 = this; this.started = true; // request the specified URL this.request = this.hls_.xhr({ uri: this.srcUrl, withCredentials: this.withCredentials }, function (error, req) { // disposed if (!_this5.request) { return; } // clear the loader's request reference _this5.request = null; if (error) { _this5.error = { status: req.status, message: 'HLS playlist request error at URL: ' + _this5.srcUrl + '.', responseText: req.responseText, // MEDIA_ERR_NETWORK code: 2 }; if (_this5.state === 'HAVE_NOTHING') { _this5.started = false; } return _this5.trigger('error'); } var parser = new Parser(); // adding custom tag parsers _this5.customTagParsers.forEach(function (customParser) { return parser.addParser(customParser); }); // adding custom tag mappers _this5.customTagMappers.forEach(function (mapper) { return parser.addTagMapper(mapper); }); parser.push(req.responseText); parser.end(); _this5.state = 'HAVE_MASTER'; _this5.srcUrl = resolveManifestRedirect(_this5.handleManifestRedirects, _this5.srcUrl, req); parser.manifest.uri = _this5.srcUrl; // loaded a master playlist if (parser.manifest.playlists) { _this5.master = parser.manifest; setupMediaPlaylists(_this5.master); resolveMediaGroupUris(_this5.master); _this5.trigger('loadedplaylist'); if (!_this5.request) { // no media playlist was specifically selected so start // from the first listed one _this5.media(parser.manifest.playlists[0]); } return; } var id = createPlaylistID(0, _this5.srcUrl); // loaded a media playlist // infer a master playlist if none was previously requested _this5.master = { mediaGroups: { 'AUDIO': {}, 'VIDEO': {}, 'CLOSED-CAPTIONS': {}, 'SUBTITLES': {} }, uri: window$1.location.href, playlists: [{ uri: _this5.srcUrl, id: id, resolvedUri: _this5.srcUrl, // m3u8-parser does not attach an attributes property to media playlists so make // sure that the property is attached to avoid undefined reference errors attributes: {} }] }; _this5.master.playlists[id] = _this5.master.playlists[0]; // URI reference added for backwards compatibility _this5.master.playlists[_this5.srcUrl] = _this5.master.playlists[0]; _this5.haveMetadata(req, _this5.srcUrl, id); return _this5.trigger('loadedmetadata'); }); } }]); return PlaylistLoader; }(EventTarget); /** * @file playlist.js * * Playlist related utilities. */ var createTimeRange = videojs.createTimeRange; /** * walk backward until we find a duration we can use * or return a failure * * @param {Playlist} playlist the playlist to walk through * @param {Number} endSequence the mediaSequence to stop walking on */ var backwardDuration = function backwardDuration(playlist, endSequence) { var result = 0; var i = endSequence - playlist.mediaSequence; // if a start time is available for segment immediately following // the interval, use it var segment = playlist.segments[i]; // Walk backward until we find the latest segment with timeline // information that is earlier than endSequence if (segment) { if (typeof segment.start !== 'undefined') { return { result: segment.start, precise: true }; } if (typeof segment.end !== 'undefined') { return { result: segment.end - segment.duration, precise: true }; } } while (i--) { segment = playlist.segments[i]; if (typeof segment.end !== 'undefined') { return { result: result + segment.end, precise: true }; } result += segment.duration; if (typeof segment.start !== 'undefined') { return { result: result + segment.start, precise: true }; } } return { result: result, precise: false }; }; /** * walk forward until we find a duration we can use * or return a failure * * @param {Playlist} playlist the playlist to walk through * @param {Number} endSequence the mediaSequence to stop walking on */ var forwardDuration = function forwardDuration(playlist, endSequence) { var result = 0; var segment = void 0; var i = endSequence - playlist.mediaSequence; // Walk forward until we find the earliest segment with timeline // information for (; i < playlist.segments.length; i++) { segment = playlist.segments[i]; if (typeof segment.start !== 'undefined') { return { result: segment.start - result, precise: true }; } result += segment.duration; if (typeof segment.end !== 'undefined') { return { result: segment.end - result, precise: true }; } } // indicate we didn't find a useful duration estimate return { result: -1, precise: false }; }; /** * Calculate the media duration from the segments associated with a * playlist. The duration of a subinterval of the available segments * may be calculated by specifying an end index. * * @param {Object} playlist a media playlist object * @param {Number=} endSequence an exclusive upper boundary * for the playlist. Defaults to playlist length. * @param {Number} expired the amount of time that has dropped * off the front of the playlist in a live scenario * @return {Number} the duration between the first available segment * and end index. */ var intervalDuration = function intervalDuration(playlist, endSequence, expired) { var backward = void 0; var forward = void 0; if (typeof endSequence === 'undefined') { endSequence = playlist.mediaSequence + playlist.segments.length; } if (endSequence < playlist.mediaSequence) { return 0; } // do a backward walk to estimate the duration backward = backwardDuration(playlist, endSequence); if (backward.precise) { // if we were able to base our duration estimate on timing // information provided directly from the Media Source, return // it return backward.result; } // walk forward to see if a precise duration estimate can be made // that way forward = forwardDuration(playlist, endSequence); if (forward.precise) { // we found a segment that has been buffered and so it's // position is known precisely return forward.result; } // return the less-precise, playlist-based duration estimate return backward.result + expired; }; /** * Calculates the duration of a playlist. If a start and end index * are specified, the duration will be for the subset of the media * timeline between those two indices. The total duration for live * playlists is always Infinity. * * @param {Object} playlist a media playlist object * @param {Number=} endSequence an exclusive upper * boundary for the playlist. Defaults to the playlist media * sequence number plus its length. * @param {Number=} expired the amount of time that has * dropped off the front of the playlist in a live scenario * @return {Number} the duration between the start index and end * index. */ var duration = function duration(playlist, endSequence, expired) { if (!playlist) { return 0; } if (typeof expired !== 'number') { expired = 0; } // if a slice of the total duration is not requested, use // playlist-level duration indicators when they're present if (typeof endSequence === 'undefined') { // if present, use the duration specified in the playlist if (playlist.totalDuration) { return playlist.totalDuration; } // duration should be Infinity for live playlists if (!playlist.endList) { return window$1.Infinity; } } // calculate the total duration based on the segment durations return intervalDuration(playlist, endSequence, expired); }; /** * Calculate the time between two indexes in the current playlist * neight the start- nor the end-index need to be within the current * playlist in which case, the targetDuration of the playlist is used * to approximate the durations of the segments * * @param {Object} playlist a media playlist object * @param {Number} startIndex * @param {Number} endIndex * @return {Number} the number of seconds between startIndex and endIndex */ var sumDurations = function sumDurations(playlist, startIndex, endIndex) { var durations = 0; if (startIndex > endIndex) { var _ref = [endIndex, startIndex]; startIndex = _ref[0]; endIndex = _ref[1]; } if (startIndex < 0) { for (var i = startIndex; i < Math.min(0, endIndex); i++) { durations += playlist.targetDuration; } startIndex = 0; } for (var _i = startIndex; _i < endIndex; _i++) { durations += playlist.segments[_i].duration; } return durations; }; /** * Determines the media index of the segment corresponding to the safe edge of the live * window which is the duration of the last segment plus 2 target durations from the end * of the playlist. * * A liveEdgePadding can be provided which will be used instead of calculating the safe live edge. * This corresponds to suggestedPresentationDelay in DASH manifests. * * @param {Object} playlist * a media playlist object * @param {Number} [liveEdgePadding] * A number in seconds indicating how far from the end we want to be. * If provided, this value is used instead of calculating the safe live index from the target durations. * Corresponds to suggestedPresentationDelay in DASH manifests. * @return {Number} * The media index of the segment at the safe live point. 0 if there is no "safe" * point. * @function safeLiveIndex */ var safeLiveIndex = function safeLiveIndex(playlist, liveEdgePadding) { if (!playlist.segments.length) { return 0; } var i = playlist.segments.length; var lastSegmentDuration = playlist.segments[i - 1].duration || playlist.targetDuration; var safeDistance = typeof liveEdgePadding === 'number' ? liveEdgePadding : lastSegmentDuration + playlist.targetDuration * 2; if (safeDistance === 0) { return i; } var distanceFromEnd = 0; while (i--) { distanceFromEnd += playlist.segments[i].duration; if (distanceFromEnd >= safeDistance) { break; } } return Math.max(0, i); }; /** * Calculates the playlist end time * * @param {Object} playlist a media playlist object * @param {Number=} expired the amount of time that has * dropped off the front of the playlist in a live scenario * @param {Boolean|false} useSafeLiveEnd a boolean value indicating whether or not the * playlist end calculation should consider the safe live end * (truncate the playlist end by three segments). This is normally * used for calculating the end of the playlist's seekable range. * This takes into account the value of liveEdgePadding. * Setting liveEdgePadding to 0 is equivalent to setting this to false. * @param {Number} liveEdgePadding a number indicating how far from the end of the playlist we should be in seconds. * If this is provided, it is used in the safe live end calculation. * Setting useSafeLiveEnd=false or liveEdgePadding=0 are equivalent. * Corresponds to suggestedPresentationDelay in DASH manifests. * @returns {Number} the end time of playlist * @function playlistEnd */ var playlistEnd = function playlistEnd(playlist, expired, useSafeLiveEnd, liveEdgePadding) { if (!playlist || !playlist.segments) { return null; } if (playlist.endList) { return duration(playlist); } if (expired === null) { return null; } expired = expired || 0; var endSequence = useSafeLiveEnd ? safeLiveIndex(playlist, liveEdgePadding) : playlist.segments.length; return intervalDuration(playlist, playlist.mediaSequence + endSequence, expired); }; /** * Calculates the interval of time that is currently seekable in a * playlist. The returned time ranges are relative to the earliest * moment in the specified playlist that is still available. A full * seekable implementation for live streams would need to offset * these values by the duration of content that has expired from the * stream. * * @param {Object} playlist a media playlist object * dropped off the front of the playlist in a live scenario * @param {Number=} expired the amount of time that has * dropped off the front of the playlist in a live scenario * @param {Number} liveEdgePadding how far from the end of the playlist we should be in seconds. * Corresponds to suggestedPresentationDelay in DASH manifests. * @return {TimeRanges} the periods of time that are valid targets * for seeking */ var seekable = function seekable(playlist, expired, liveEdgePadding) { var useSafeLiveEnd = true; var seekableStart = expired || 0; var seekableEnd = playlistEnd(playlist, expired, useSafeLiveEnd, liveEdgePadding); if (seekableEnd === null) { return createTimeRange(); } return createTimeRange(seekableStart, seekableEnd); }; var isWholeNumber = function isWholeNumber(num) { return num - Math.floor(num) === 0; }; var roundSignificantDigit = function roundSignificantDigit(increment, num) { // If we have a whole number, just add 1 to it if (isWholeNumber(num)) { return num + increment * 0.1; } var numDecimalDigits = num.toString().split('.')[1].length; for (var i = 1; i <= numDecimalDigits; i++) { var scale = Math.pow(10, i); var temp = num * scale; if (isWholeNumber(temp) || i === numDecimalDigits) { return (temp + increment) / scale; } } }; var ceilLeastSignificantDigit = roundSignificantDigit.bind(null, 1); var floorLeastSignificantDigit = roundSignificantDigit.bind(null, -1); /** * Determine the index and estimated starting time of the segment that * contains a specified playback position in a media playlist. * * @param {Object} playlist the media playlist to query * @param {Number} currentTime The number of seconds since the earliest * possible position to determine the containing segment for * @param {Number} startIndex * @param {Number} startTime * @return {Object} */ var getMediaInfoForTime = function getMediaInfoForTime(playlist, currentTime, startIndex, startTime) { var i = void 0; var segment = void 0; var numSegments = playlist.segments.length; var time = currentTime - startTime; if (time < 0) { // Walk backward from startIndex in the playlist, adding durations // until we find a segment that contains `time` and return it if (startIndex > 0) { for (i = startIndex - 1; i >= 0; i--) { segment = playlist.segments[i]; time += floorLeastSignificantDigit(segment.duration); if (time > 0) { return { mediaIndex: i, startTime: startTime - sumDurations(playlist, startIndex, i) }; } } } // We were unable to find a good segment within the playlist // so select the first segment return { mediaIndex: 0, startTime: currentTime }; } // When startIndex is negative, we first walk forward to first segment // adding target durations. If we "run out of time" before getting to // the first segment, return the first segment if (startIndex < 0) { for (i = startIndex; i < 0; i++) { time -= playlist.targetDuration; if (time < 0) { return { mediaIndex: 0, startTime: currentTime }; } } startIndex = 0; } // Walk forward from startIndex in the playlist, subtracting durations // until we find a segment that contains `time` and return it for (i = startIndex; i < numSegments; i++) { segment = playlist.segments[i]; time -= ceilLeastSignificantDigit(segment.duration); if (time < 0) { return { mediaIndex: i, startTime: startTime + sumDurations(playlist, startIndex, i) }; } } // We are out of possible candidates so load the last one... return { mediaIndex: numSegments - 1, startTime: currentTime }; }; /** * Check whether the playlist is blacklisted or not. * * @param {Object} playlist the media playlist object * @return {boolean} whether the playlist is blacklisted or not * @function isBlacklisted */ var isBlacklisted = function isBlacklisted(playlist) { return playlist.excludeUntil && playlist.excludeUntil > Date.now(); }; /** * Check whether the playlist is compatible with current playback configuration or has * been blacklisted permanently for being incompatible. * * @param {Object} playlist the media playlist object * @return {boolean} whether the playlist is incompatible or not * @function isIncompatible */ var isIncompatible = function isIncompatible(playlist) { return playlist.excludeUntil && playlist.excludeUntil === Infinity; }; /** * Check whether the playlist is enabled or not. * * @param {Object} playlist the media playlist object * @return {boolean} whether the playlist is enabled or not * @function isEnabled */ var isEnabled = function isEnabled(playlist) { var blacklisted = isBlacklisted(playlist); return !playlist.disabled && !blacklisted; }; /** * Check whether the playlist has been manually disabled through the representations api. * * @param {Object} playlist the media playlist object * @return {boolean} whether the playlist is disabled manually or not * @function isDisabled */ var isDisabled = function isDisabled(playlist) { return playlist.disabled; }; /** * Returns whether the current playlist is an AES encrypted HLS stream * * @return {Boolean} true if it's an AES encrypted HLS stream */ var isAes = function isAes(media) { for (var i = 0; i < media.segments.length; i++) { if (media.segments[i].key) { return true; } } return false; }; /** * Returns whether the current playlist contains fMP4 * * @return {Boolean} true if the playlist contains fMP4 */ var isFmp4 = function isFmp4(media) { for (var i = 0; i < media.segments.length; i++) { if (media.segments[i].map) { return true; } } return false; }; /** * Checks if the playlist has a value for the specified attribute * * @param {String} attr * Attribute to check for * @param {Object} playlist * The media playlist object * @return {Boolean} * Whether the playlist contains a value for the attribute or not * @function hasAttribute */ var hasAttribute = function hasAttribute(attr, playlist) { return playlist.attributes && playlist.attributes[attr]; }; /** * Estimates the time required to complete a segment download from the specified playlist * * @param {Number} segmentDuration * Duration of requested segment * @param {Number} bandwidth * Current measured bandwidth of the player * @param {Object} playlist * The media playlist object * @param {Number=} bytesReceived * Number of bytes already received for the request. Defaults to 0 * @return {Number|NaN} * The estimated time to request the segment. NaN if bandwidth information for * the given playlist is unavailable * @function estimateSegmentRequestTime */ var estimateSegmentRequestTime = function estimateSegmentRequestTime(segmentDuration, bandwidth, playlist) { var bytesReceived = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0; if (!hasAttribute('BANDWIDTH', playlist)) { return NaN; } var size = segmentDuration * playlist.attributes.BANDWIDTH; return (size - bytesReceived * 8) / bandwidth; }; /* * Returns whether the current playlist is the lowest rendition * * @return {Boolean} true if on lowest rendition */ var isLowestEnabledRendition = function isLowestEnabledRendition(master, media) { if (master.playlists.length === 1) { return true; } var currentBandwidth = media.attributes.BANDWIDTH || Number.MAX_VALUE; return master.playlists.filter(function (playlist) { if (!isEnabled(playlist)) { return false; } return (playlist.attributes.BANDWIDTH || 0) < currentBandwidth; }).length === 0; }; // exports var Playlist = { duration: duration, seekable: seekable, safeLiveIndex: safeLiveIndex, getMediaInfoForTime: getMediaInfoForTime, isEnabled: isEnabled, isDisabled: isDisabled, isBlacklisted: isBlacklisted, isIncompatible: isIncompatible, playlistEnd: playlistEnd, isAes: isAes, isFmp4: isFmp4, hasAttribute: hasAttribute, estimateSegmentRequestTime: estimateSegmentRequestTime, isLowestEnabledRendition: isLowestEnabledRendition }; /** * @file xhr.js */ var videojsXHR = videojs.xhr, mergeOptions$1 = videojs.mergeOptions; var xhrFactory = function xhrFactory() { var xhr = function XhrFunction(options, callback) { // Add a default timeout for all hls requests options = mergeOptions$1({ timeout: 45e3 }, options); // Allow an optional user-specified function to modify the option // object before we construct the xhr request var beforeRequest = XhrFunction.beforeRequest || videojs.Hls.xhr.beforeRequest; if (beforeRequest && typeof beforeRequest === 'function') { var newOptions = beforeRequest(options); if (newOptions) { options = newOptions; } } var request = videojsXHR(options, function (error, response) { var reqResponse = request.response; if (!error && reqResponse) { request.responseTime = Date.now(); request.roundTripTime = request.responseTime - request.requestTime; request.bytesReceived = reqResponse.byteLength || reqResponse.length; if (!request.bandwidth) { request.bandwidth = Math.floor(request.bytesReceived / request.roundTripTime * 8 * 1000); } } if (response.headers) { request.responseHeaders = response.headers; } // videojs.xhr now uses a specific code on the error // object to signal that a request has timed out instead // of setting a boolean on the request object if (error && error.code === 'ETIMEDOUT') { request.timedout = true; } // videojs.xhr no longer considers status codes outside of 200 and 0 // (for file uris) to be errors, but the old XHR did, so emulate that // behavior. Status 206 may be used in response to byterange requests. if (!error && !request.aborted && response.statusCode !== 200 && response.statusCode !== 206 && response.statusCode !== 0) { error = new Error('XHR Failed with a response of: ' + (request && (reqResponse || request.responseText))); } callback(error, request); }); var originalAbort = request.abort; request.abort = function () { request.aborted = true; return originalAbort.apply(request, arguments); }; request.uri = options.uri; request.requestTime = Date.now(); return request; }; return xhr; }; /** * Turns segment byterange into a string suitable for use in * HTTP Range requests * * @param {Object} byterange - an object with two values defining the start and end * of a byte-range */ var byterangeStr = function byterangeStr(byterange) { var byterangeStart = void 0; var byterangeEnd = void 0; // `byterangeEnd` is one less than `offset + length` because the HTTP range // header uses inclusive ranges byterangeEnd = byterange.offset + byterange.length - 1; byterangeStart = byterange.offset; return 'bytes=' + byterangeStart + '-' + byterangeEnd; }; /** * Defines headers for use in the xhr request for a particular segment. * * @param {Object} segment - a simplified copy of the segmentInfo object * from SegmentLoader */ var segmentXhrHeaders = function segmentXhrHeaders(segment) { var headers = {}; if (segment.byterange) { headers.Range = byterangeStr(segment.byterange); } return headers; }; /** * @file bin-utils.js */ /** * convert a TimeRange to text * * @param {TimeRange} range the timerange to use for conversion * @param {Number} i the iterator on the range to convert */ var textRange = function textRange(range, i) { return range.start(i) + '-' + range.end(i); }; /** * format a number as hex string * * @param {Number} e The number * @param {Number} i the iterator */ var formatHexString = function formatHexString(e, i) { var value = e.toString(16); return '00'.substring(0, 2 - value.length) + value + (i % 2 ? ' ' : ''); }; var formatAsciiString = function formatAsciiString(e) { if (e >= 0x20 && e < 0x7e) { return String.fromCharCode(e); } return '.'; }; /** * Creates an object for sending to a web worker modifying properties that are TypedArrays * into a new object with seperated properties for the buffer, byteOffset, and byteLength. * * @param {Object} message * Object of properties and values to send to the web worker * @return {Object} * Modified message with TypedArray values expanded * @function createTransferableMessage */ var createTransferableMessage = function createTransferableMessage(message) { var transferable = {}; Object.keys(message).forEach(function (key) { var value = message[key]; if (ArrayBuffer.isView(value)) { transferable[key] = { bytes: value.buffer, byteOffset: value.byteOffset, byteLength: value.byteLength }; } else { transferable[key] = value; } }); return transferable; }; /** * Returns a unique string identifier for a media initialization * segment. */ var initSegmentId = function initSegmentId(initSegment) { var byterange = initSegment.byterange || { length: Infinity, offset: 0 }; return [byterange.length, byterange.offset, initSegment.resolvedUri].join(','); }; /** * Returns a unique string identifier for a media segment key. */ var segmentKeyId = function segmentKeyId(key) { return key.resolvedUri; }; /** * utils to help dump binary data to the console */ var hexDump = function hexDump(data) { var bytes = Array.prototype.slice.call(data); var step = 16; var result = ''; var hex = void 0; var ascii = void 0; for (var j = 0; j < bytes.length / step; j++) { hex = bytes.slice(j * step, j * step + step).map(formatHexString).join(''); ascii = bytes.slice(j * step, j * step + step).map(formatAsciiString).join(''); result += hex + ' ' + ascii + '\n'; } return result; }; var tagDump = function tagDump(_ref) { var bytes = _ref.bytes; return hexDump(bytes); }; var textRanges = function textRanges(ranges) { var result = ''; var i = void 0; for (i = 0; i < ranges.length; i++) { result += textRange(ranges, i) + ' '; } return result; }; var utils = /*#__PURE__*/Object.freeze({ createTransferableMessage: createTransferableMessage, initSegmentId: initSegmentId, segmentKeyId: segmentKeyId, hexDump: hexDump, tagDump: tagDump, textRanges: textRanges }); // TODO handle fmp4 case where the timing info is accurate and doesn't involve transmux // Add 25% to the segment duration to account for small discrepencies in segment timing. // 25% was arbitrarily chosen, and may need to be refined over time. var SEGMENT_END_FUDGE_PERCENT = 0.25; /** * Co