628 lines
21 KiB
JavaScript
628 lines
21 KiB
JavaScript
/**
|
|
* @module playlist-loader
|
|
*
|
|
* @file A state machine that manages the loading, caching, and updating of
|
|
* M3U8 playlists.
|
|
*/
|
|
'use strict';
|
|
|
|
Object.defineProperty(exports, '__esModule', {
|
|
value: true
|
|
});
|
|
|
|
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(_x, _x2, _x3) { var _again = true; _function: while (_again) { var object = _x, property = _x2, receiver = _x3; _again = false; 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 { _x = parent; _x2 = property; _x3 = receiver; _again = true; desc = parent = undefined; continue _function; } } else if ('value' in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } } };
|
|
|
|
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }
|
|
|
|
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } }
|
|
|
|
function _inherits(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 _resolveUrl = require('./resolve-url');
|
|
|
|
var _resolveUrl2 = _interopRequireDefault(_resolveUrl);
|
|
|
|
var _videoJs = require('video.js');
|
|
|
|
var _m3u8Parser = require('m3u8-parser');
|
|
|
|
var _m3u8Parser2 = _interopRequireDefault(_m3u8Parser);
|
|
|
|
var _globalWindow = require('global/window');
|
|
|
|
var _globalWindow2 = _interopRequireDefault(_globalWindow);
|
|
|
|
/**
|
|
* 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] = (0, _videoJs.mergeOptions)(original[i], result[i - offset]);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
exports.updateSegments = updateSegments;
|
|
var resolveSegmentUris = function resolveSegmentUris(segment, baseUri) {
|
|
if (!segment.resolvedUri) {
|
|
segment.resolvedUri = (0, _resolveUrl2['default'])(baseUri, segment.uri);
|
|
}
|
|
if (segment.key && !segment.key.resolvedUri) {
|
|
segment.key.resolvedUri = (0, _resolveUrl2['default'])(baseUri, segment.key.uri);
|
|
}
|
|
if (segment.map && !segment.map.resolvedUri) {
|
|
segment.map.resolvedUri = (0, _resolveUrl2['default'])(baseUri, segment.map.uri);
|
|
}
|
|
};
|
|
|
|
exports.resolveSegmentUris = resolveSegmentUris;
|
|
/**
|
|
* 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 = (0, _videoJs.mergeOptions)(master, {});
|
|
var playlist = result.playlists.filter(function (p) {
|
|
return p.uri === media.uri;
|
|
})[0];
|
|
|
|
if (!playlist) {
|
|
return null;
|
|
}
|
|
|
|
// consider the playlist unchanged if the number of segments is equal and the media
|
|
// sequence number is unchanged
|
|
if (playlist.segments && media.segments && playlist.segments.length === media.segments.length && playlist.mediaSequence === media.mediaSequence) {
|
|
return null;
|
|
}
|
|
|
|
var mergedPlaylist = (0, _videoJs.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].uri === media.uri) {
|
|
result.playlists[i] = mergedPlaylist;
|
|
}
|
|
}
|
|
result.playlists[media.uri] = mergedPlaylist;
|
|
|
|
return result;
|
|
};
|
|
|
|
exports.updateMaster = updateMaster;
|
|
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];
|
|
|
|
master.playlists[playlist.uri] = playlist;
|
|
playlist.resolvedUri = (0, _resolveUrl2['default'])(master.uri, playlist.uri);
|
|
|
|
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 = {};
|
|
|
|
_videoJs.log.warn('Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.');
|
|
}
|
|
}
|
|
};
|
|
|
|
exports.setupMediaPlaylists = setupMediaPlaylists;
|
|
var resolveMediaGroupUris = function resolveMediaGroupUris(master) {
|
|
['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];
|
|
|
|
if (mediaProperties.uri) {
|
|
mediaProperties.resolvedUri = (0, _resolveUrl2['default'])(master.uri, mediaProperties.uri);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
exports.resolveMediaGroupUris = resolveMediaGroupUris;
|
|
/**
|
|
* 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 = undefined;
|
|
|
|
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;
|
|
};
|
|
|
|
exports.refreshDelay = refreshDelay;
|
|
/**
|
|
* Load a playlist from a remote location
|
|
*
|
|
* @class PlaylistLoader
|
|
* @extends videojs.EventTarget
|
|
* @param {String} srcUrl the url to start with
|
|
* @param {Object} hls
|
|
* @param {Object} [options]
|
|
* @param {Boolean} [options.withCredentials=false] the withCredentials xhr option
|
|
* @param {Boolean} [options.handleManifestRedirects=false] whether to follow redirects, when any
|
|
* playlist request was redirected
|
|
*/
|
|
|
|
var PlaylistLoader = (function (_EventTarget) {
|
|
_inherits(PlaylistLoader, _EventTarget);
|
|
|
|
function PlaylistLoader(srcUrl, hls, options) {
|
|
var _this = this;
|
|
|
|
_classCallCheck(this, PlaylistLoader);
|
|
|
|
_get(Object.getPrototypeOf(PlaylistLoader.prototype), 'constructor', this).call(this);
|
|
|
|
options = options || {};
|
|
|
|
this.srcUrl = srcUrl;
|
|
this.hls_ = hls;
|
|
this.withCredentials = !!options.withCredentials;
|
|
this.handleManifestRedirects = !!options.handleManifestRedirects;
|
|
|
|
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: (0, _resolveUrl2['default'])(_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().uri, 'HAVE_METADATA');
|
|
}
|
|
|
|
_this.haveMetadata(_this.request, _this.media().uri);
|
|
});
|
|
});
|
|
}
|
|
|
|
_createClass(PlaylistLoader, [{
|
|
key: 'playlistRequestError',
|
|
value: function playlistRequestError(xhr, url, startingState) {
|
|
// any in-flight request is now finished
|
|
this.request = null;
|
|
|
|
if (startingState) {
|
|
this.state = startingState;
|
|
}
|
|
|
|
this.error = {
|
|
playlist: this.master.playlists[url],
|
|
status: xhr.status,
|
|
message: 'HLS playlist request error at URL: ' + url,
|
|
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) {
|
|
var _this2 = this;
|
|
|
|
// any in-flight request is now finished
|
|
this.request = null;
|
|
this.state = 'HAVE_METADATA';
|
|
|
|
var parser = new _m3u8Parser2['default'].Parser();
|
|
|
|
parser.push(xhr.responseText);
|
|
parser.end();
|
|
parser.manifest.uri = url;
|
|
// 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[parser.manifest.uri];
|
|
} else {
|
|
this.trigger('playlistunchanged');
|
|
}
|
|
|
|
// refresh live playlists after a target duration passes
|
|
if (!this.media().endList) {
|
|
_globalWindow2['default'].clearTimeout(this.mediaUpdateTimeout);
|
|
this.mediaUpdateTimeout = _globalWindow2['default'].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.stopRequest();
|
|
_globalWindow2['default'].clearTimeout(this.mediaUpdateTimeout);
|
|
}
|
|
}, {
|
|
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
|
|
* @return {Playlist} the current loaded media
|
|
*/
|
|
}, {
|
|
key: 'media',
|
|
value: function media(playlist) {
|
|
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);
|
|
}
|
|
|
|
var startingState = 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];
|
|
}
|
|
|
|
var mediaChange = !this.media_ || playlist.uri !== this.media_.uri;
|
|
|
|
// switch to fully loaded playlists immediately
|
|
if (this.master.playlists[playlist.uri].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 = _this3.resolveManifestRedirect(playlist.resolvedUri, req);
|
|
|
|
if (error) {
|
|
return _this3.playlistRequestError(_this3.request, playlist.uri, startingState);
|
|
}
|
|
|
|
_this3.haveMetadata(req, playlist.uri);
|
|
|
|
// fire loadedmetadata the first time a media playlist is loaded
|
|
if (startingState === 'HAVE_MASTER') {
|
|
_this3.trigger('loadedmetadata');
|
|
} else {
|
|
_this3.trigger('mediachange');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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}
|
|
*/
|
|
}, {
|
|
key: 'resolveManifestRedirect',
|
|
value: function resolveManifestRedirect(url, req) {
|
|
if (this.handleManifestRedirects && req.responseURL && url !== req.responseURL) {
|
|
return req.responseURL;
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* pause loading of the playlist
|
|
*/
|
|
}, {
|
|
key: 'pause',
|
|
value: function pause() {
|
|
this.stopRequest();
|
|
_globalWindow2['default'].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;
|
|
|
|
_globalWindow2['default'].clearTimeout(this.mediaUpdateTimeout);
|
|
|
|
var media = this.media();
|
|
|
|
if (isFinalRendition) {
|
|
var delay = media ? media.targetDuration / 2 * 1000 : 5 * 1000;
|
|
|
|
this.mediaUpdateTimeout = _globalWindow2['default'].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 _m3u8Parser2['default'].Parser();
|
|
|
|
parser.push(req.responseText);
|
|
parser.end();
|
|
|
|
_this5.state = 'HAVE_MASTER';
|
|
|
|
_this5.srcUrl = _this5.resolveManifestRedirect(_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;
|
|
}
|
|
|
|
// loaded a media playlist
|
|
// infer a master playlist if none was previously requested
|
|
_this5.master = {
|
|
mediaGroups: {
|
|
'AUDIO': {},
|
|
'VIDEO': {},
|
|
'CLOSED-CAPTIONS': {},
|
|
'SUBTITLES': {}
|
|
},
|
|
uri: _globalWindow2['default'].location.href,
|
|
playlists: [{
|
|
uri: _this5.srcUrl,
|
|
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[_this5.srcUrl] = _this5.master.playlists[0];
|
|
_this5.haveMetadata(req, _this5.srcUrl);
|
|
return _this5.trigger('loadedmetadata');
|
|
});
|
|
}
|
|
}]);
|
|
|
|
return PlaylistLoader;
|
|
})(_videoJs.EventTarget);
|
|
|
|
exports['default'] = PlaylistLoader; |