playlist-loader.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. /**
  2. * @module playlist-loader
  3. *
  4. * @file A state machine that manages the loading, caching, and updating of
  5. * M3U8 playlists.
  6. */
  7. 'use strict';
  8. Object.defineProperty(exports, '__esModule', {
  9. value: true
  10. });
  11. 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; }; })();
  12. 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); } } };
  13. function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }
  14. function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } }
  15. 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; }
  16. var _resolveUrl = require('./resolve-url');
  17. var _resolveUrl2 = _interopRequireDefault(_resolveUrl);
  18. var _videoJs = require('video.js');
  19. var _m3u8Parser = require('m3u8-parser');
  20. var _m3u8Parser2 = _interopRequireDefault(_m3u8Parser);
  21. var _globalWindow = require('global/window');
  22. var _globalWindow2 = _interopRequireDefault(_globalWindow);
  23. /**
  24. * Returns a new array of segments that is the result of merging
  25. * properties from an older list of segments onto an updated
  26. * list. No properties on the updated playlist will be overridden.
  27. *
  28. * @param {Array} original the outdated list of segments
  29. * @param {Array} update the updated list of segments
  30. * @param {Number=} offset the index of the first update
  31. * segment in the original segment list. For non-live playlists,
  32. * this should always be zero and does not need to be
  33. * specified. For live playlists, it should be the difference
  34. * between the media sequence numbers in the original and updated
  35. * playlists.
  36. * @return a list of merged segment objects
  37. */
  38. var updateSegments = function updateSegments(original, update, offset) {
  39. var result = update.slice();
  40. offset = offset || 0;
  41. var length = Math.min(original.length, update.length + offset);
  42. for (var i = offset; i < length; i++) {
  43. result[i - offset] = (0, _videoJs.mergeOptions)(original[i], result[i - offset]);
  44. }
  45. return result;
  46. };
  47. exports.updateSegments = updateSegments;
  48. var resolveSegmentUris = function resolveSegmentUris(segment, baseUri) {
  49. if (!segment.resolvedUri) {
  50. segment.resolvedUri = (0, _resolveUrl2['default'])(baseUri, segment.uri);
  51. }
  52. if (segment.key && !segment.key.resolvedUri) {
  53. segment.key.resolvedUri = (0, _resolveUrl2['default'])(baseUri, segment.key.uri);
  54. }
  55. if (segment.map && !segment.map.resolvedUri) {
  56. segment.map.resolvedUri = (0, _resolveUrl2['default'])(baseUri, segment.map.uri);
  57. }
  58. };
  59. exports.resolveSegmentUris = resolveSegmentUris;
  60. /**
  61. * Returns a new master playlist that is the result of merging an
  62. * updated media playlist into the original version. If the
  63. * updated media playlist does not match any of the playlist
  64. * entries in the original master playlist, null is returned.
  65. *
  66. * @param {Object} master a parsed master M3U8 object
  67. * @param {Object} media a parsed media M3U8 object
  68. * @return {Object} a new object that represents the original
  69. * master playlist with the updated media playlist merged in, or
  70. * null if the merge produced no change.
  71. */
  72. var updateMaster = function updateMaster(master, media) {
  73. var result = (0, _videoJs.mergeOptions)(master, {});
  74. var playlist = result.playlists.filter(function (p) {
  75. return p.uri === media.uri;
  76. })[0];
  77. if (!playlist) {
  78. return null;
  79. }
  80. // consider the playlist unchanged if the number of segments is equal and the media
  81. // sequence number is unchanged
  82. if (playlist.segments && media.segments && playlist.segments.length === media.segments.length && playlist.mediaSequence === media.mediaSequence) {
  83. return null;
  84. }
  85. var mergedPlaylist = (0, _videoJs.mergeOptions)(playlist, media);
  86. // if the update could overlap existing segment information, merge the two segment lists
  87. if (playlist.segments) {
  88. mergedPlaylist.segments = updateSegments(playlist.segments, media.segments, media.mediaSequence - playlist.mediaSequence);
  89. }
  90. // resolve any segment URIs to prevent us from having to do it later
  91. mergedPlaylist.segments.forEach(function (segment) {
  92. resolveSegmentUris(segment, mergedPlaylist.resolvedUri);
  93. });
  94. // TODO Right now in the playlists array there are two references to each playlist, one
  95. // that is referenced by index, and one by URI. The index reference may no longer be
  96. // necessary.
  97. for (var i = 0; i < result.playlists.length; i++) {
  98. if (result.playlists[i].uri === media.uri) {
  99. result.playlists[i] = mergedPlaylist;
  100. }
  101. }
  102. result.playlists[media.uri] = mergedPlaylist;
  103. return result;
  104. };
  105. exports.updateMaster = updateMaster;
  106. var setupMediaPlaylists = function setupMediaPlaylists(master) {
  107. // setup by-URI lookups and resolve media playlist URIs
  108. var i = master.playlists.length;
  109. while (i--) {
  110. var playlist = master.playlists[i];
  111. master.playlists[playlist.uri] = playlist;
  112. playlist.resolvedUri = (0, _resolveUrl2['default'])(master.uri, playlist.uri);
  113. if (!playlist.attributes) {
  114. // Although the spec states an #EXT-X-STREAM-INF tag MUST have a
  115. // BANDWIDTH attribute, we can play the stream without it. This means a poorly
  116. // formatted master playlist may not have an attribute list. An attributes
  117. // property is added here to prevent undefined references when we encounter
  118. // this scenario.
  119. playlist.attributes = {};
  120. _videoJs.log.warn('Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.');
  121. }
  122. }
  123. };
  124. exports.setupMediaPlaylists = setupMediaPlaylists;
  125. var resolveMediaGroupUris = function resolveMediaGroupUris(master) {
  126. ['AUDIO', 'SUBTITLES'].forEach(function (mediaType) {
  127. for (var groupKey in master.mediaGroups[mediaType]) {
  128. for (var labelKey in master.mediaGroups[mediaType][groupKey]) {
  129. var mediaProperties = master.mediaGroups[mediaType][groupKey][labelKey];
  130. if (mediaProperties.uri) {
  131. mediaProperties.resolvedUri = (0, _resolveUrl2['default'])(master.uri, mediaProperties.uri);
  132. }
  133. }
  134. }
  135. });
  136. };
  137. exports.resolveMediaGroupUris = resolveMediaGroupUris;
  138. /**
  139. * Calculates the time to wait before refreshing a live playlist
  140. *
  141. * @param {Object} media
  142. * The current media
  143. * @param {Boolean} update
  144. * True if there were any updates from the last refresh, false otherwise
  145. * @return {Number}
  146. * The time in ms to wait before refreshing the live playlist
  147. */
  148. var refreshDelay = function refreshDelay(media, update) {
  149. var lastSegment = media.segments[media.segments.length - 1];
  150. var delay = undefined;
  151. if (update && lastSegment && lastSegment.duration) {
  152. delay = lastSegment.duration * 1000;
  153. } else {
  154. // if the playlist is unchanged since the last reload or last segment duration
  155. // cannot be determined, try again after half the target duration
  156. delay = (media.targetDuration || 10) * 500;
  157. }
  158. return delay;
  159. };
  160. exports.refreshDelay = refreshDelay;
  161. /**
  162. * Load a playlist from a remote location
  163. *
  164. * @class PlaylistLoader
  165. * @extends videojs.EventTarget
  166. * @param {String} srcUrl the url to start with
  167. * @param {Object} hls
  168. * @param {Object} [options]
  169. * @param {Boolean} [options.withCredentials=false] the withCredentials xhr option
  170. * @param {Boolean} [options.handleManifestRedirects=false] whether to follow redirects, when any
  171. * playlist request was redirected
  172. */
  173. var PlaylistLoader = (function (_EventTarget) {
  174. _inherits(PlaylistLoader, _EventTarget);
  175. function PlaylistLoader(srcUrl, hls, options) {
  176. var _this = this;
  177. _classCallCheck(this, PlaylistLoader);
  178. _get(Object.getPrototypeOf(PlaylistLoader.prototype), 'constructor', this).call(this);
  179. options = options || {};
  180. this.srcUrl = srcUrl;
  181. this.hls_ = hls;
  182. this.withCredentials = !!options.withCredentials;
  183. this.handleManifestRedirects = !!options.handleManifestRedirects;
  184. if (!this.srcUrl) {
  185. throw new Error('A non-empty playlist URL is required');
  186. }
  187. // initialize the loader state
  188. this.state = 'HAVE_NOTHING';
  189. // live playlist staleness timeout
  190. this.on('mediaupdatetimeout', function () {
  191. if (_this.state !== 'HAVE_METADATA') {
  192. // only refresh the media playlist if no other activity is going on
  193. return;
  194. }
  195. _this.state = 'HAVE_CURRENT_METADATA';
  196. _this.request = _this.hls_.xhr({
  197. uri: (0, _resolveUrl2['default'])(_this.master.uri, _this.media().uri),
  198. withCredentials: _this.withCredentials
  199. }, function (error, req) {
  200. // disposed
  201. if (!_this.request) {
  202. return;
  203. }
  204. if (error) {
  205. return _this.playlistRequestError(_this.request, _this.media().uri, 'HAVE_METADATA');
  206. }
  207. _this.haveMetadata(_this.request, _this.media().uri);
  208. });
  209. });
  210. }
  211. _createClass(PlaylistLoader, [{
  212. key: 'playlistRequestError',
  213. value: function playlistRequestError(xhr, url, startingState) {
  214. // any in-flight request is now finished
  215. this.request = null;
  216. if (startingState) {
  217. this.state = startingState;
  218. }
  219. this.error = {
  220. playlist: this.master.playlists[url],
  221. status: xhr.status,
  222. message: 'HLS playlist request error at URL: ' + url,
  223. responseText: xhr.responseText,
  224. code: xhr.status >= 500 ? 4 : 2
  225. };
  226. this.trigger('error');
  227. }
  228. // update the playlist loader's state in response to a new or
  229. // updated playlist.
  230. }, {
  231. key: 'haveMetadata',
  232. value: function haveMetadata(xhr, url) {
  233. var _this2 = this;
  234. // any in-flight request is now finished
  235. this.request = null;
  236. this.state = 'HAVE_METADATA';
  237. var parser = new _m3u8Parser2['default'].Parser();
  238. parser.push(xhr.responseText);
  239. parser.end();
  240. parser.manifest.uri = url;
  241. // m3u8-parser does not attach an attributes property to media playlists so make
  242. // sure that the property is attached to avoid undefined reference errors
  243. parser.manifest.attributes = parser.manifest.attributes || {};
  244. // merge this playlist into the master
  245. var update = updateMaster(this.master, parser.manifest);
  246. this.targetDuration = parser.manifest.targetDuration;
  247. if (update) {
  248. this.master = update;
  249. this.media_ = this.master.playlists[parser.manifest.uri];
  250. } else {
  251. this.trigger('playlistunchanged');
  252. }
  253. // refresh live playlists after a target duration passes
  254. if (!this.media().endList) {
  255. _globalWindow2['default'].clearTimeout(this.mediaUpdateTimeout);
  256. this.mediaUpdateTimeout = _globalWindow2['default'].setTimeout(function () {
  257. _this2.trigger('mediaupdatetimeout');
  258. }, refreshDelay(this.media(), !!update));
  259. }
  260. this.trigger('loadedplaylist');
  261. }
  262. /**
  263. * Abort any outstanding work and clean up.
  264. */
  265. }, {
  266. key: 'dispose',
  267. value: function dispose() {
  268. this.stopRequest();
  269. _globalWindow2['default'].clearTimeout(this.mediaUpdateTimeout);
  270. }
  271. }, {
  272. key: 'stopRequest',
  273. value: function stopRequest() {
  274. if (this.request) {
  275. var oldRequest = this.request;
  276. this.request = null;
  277. oldRequest.onreadystatechange = null;
  278. oldRequest.abort();
  279. }
  280. }
  281. /**
  282. * When called without any arguments, returns the currently
  283. * active media playlist. When called with a single argument,
  284. * triggers the playlist loader to asynchronously switch to the
  285. * specified media playlist. Calling this method while the
  286. * loader is in the HAVE_NOTHING causes an error to be emitted
  287. * but otherwise has no effect.
  288. *
  289. * @param {Object=} playlist the parsed media playlist
  290. * object to switch to
  291. * @return {Playlist} the current loaded media
  292. */
  293. }, {
  294. key: 'media',
  295. value: function media(playlist) {
  296. var _this3 = this;
  297. // getter
  298. if (!playlist) {
  299. return this.media_;
  300. }
  301. // setter
  302. if (this.state === 'HAVE_NOTHING') {
  303. throw new Error('Cannot switch media playlist from ' + this.state);
  304. }
  305. var startingState = this.state;
  306. // find the playlist object if the target playlist has been
  307. // specified by URI
  308. if (typeof playlist === 'string') {
  309. if (!this.master.playlists[playlist]) {
  310. throw new Error('Unknown playlist URI: ' + playlist);
  311. }
  312. playlist = this.master.playlists[playlist];
  313. }
  314. var mediaChange = !this.media_ || playlist.uri !== this.media_.uri;
  315. // switch to fully loaded playlists immediately
  316. if (this.master.playlists[playlist.uri].endList) {
  317. // abort outstanding playlist requests
  318. if (this.request) {
  319. this.request.onreadystatechange = null;
  320. this.request.abort();
  321. this.request = null;
  322. }
  323. this.state = 'HAVE_METADATA';
  324. this.media_ = playlist;
  325. // trigger media change if the active media has been updated
  326. if (mediaChange) {
  327. this.trigger('mediachanging');
  328. this.trigger('mediachange');
  329. }
  330. return;
  331. }
  332. // switching to the active playlist is a no-op
  333. if (!mediaChange) {
  334. return;
  335. }
  336. this.state = 'SWITCHING_MEDIA';
  337. // there is already an outstanding playlist request
  338. if (this.request) {
  339. if (playlist.resolvedUri === this.request.url) {
  340. // requesting to switch to the same playlist multiple times
  341. // has no effect after the first
  342. return;
  343. }
  344. this.request.onreadystatechange = null;
  345. this.request.abort();
  346. this.request = null;
  347. }
  348. // request the new playlist
  349. if (this.media_) {
  350. this.trigger('mediachanging');
  351. }
  352. this.request = this.hls_.xhr({
  353. uri: playlist.resolvedUri,
  354. withCredentials: this.withCredentials
  355. }, function (error, req) {
  356. // disposed
  357. if (!_this3.request) {
  358. return;
  359. }
  360. playlist.resolvedUri = _this3.resolveManifestRedirect(playlist.resolvedUri, req);
  361. if (error) {
  362. return _this3.playlistRequestError(_this3.request, playlist.uri, startingState);
  363. }
  364. _this3.haveMetadata(req, playlist.uri);
  365. // fire loadedmetadata the first time a media playlist is loaded
  366. if (startingState === 'HAVE_MASTER') {
  367. _this3.trigger('loadedmetadata');
  368. } else {
  369. _this3.trigger('mediachange');
  370. }
  371. });
  372. }
  373. /**
  374. * Checks whether xhr request was redirected and returns correct url depending
  375. * on `handleManifestRedirects` option
  376. *
  377. * @api private
  378. *
  379. * @param {String} url - an url being requested
  380. * @param {XMLHttpRequest} req - xhr request result
  381. *
  382. * @return {String}
  383. */
  384. }, {
  385. key: 'resolveManifestRedirect',
  386. value: function resolveManifestRedirect(url, req) {
  387. if (this.handleManifestRedirects && req.responseURL && url !== req.responseURL) {
  388. return req.responseURL;
  389. }
  390. return url;
  391. }
  392. /**
  393. * pause loading of the playlist
  394. */
  395. }, {
  396. key: 'pause',
  397. value: function pause() {
  398. this.stopRequest();
  399. _globalWindow2['default'].clearTimeout(this.mediaUpdateTimeout);
  400. if (this.state === 'HAVE_NOTHING') {
  401. // If we pause the loader before any data has been retrieved, its as if we never
  402. // started, so reset to an unstarted state.
  403. this.started = false;
  404. }
  405. // Need to restore state now that no activity is happening
  406. if (this.state === 'SWITCHING_MEDIA') {
  407. // if the loader was in the process of switching media, it should either return to
  408. // HAVE_MASTER or HAVE_METADATA depending on if the loader has loaded a media
  409. // playlist yet. This is determined by the existence of loader.media_
  410. if (this.media_) {
  411. this.state = 'HAVE_METADATA';
  412. } else {
  413. this.state = 'HAVE_MASTER';
  414. }
  415. } else if (this.state === 'HAVE_CURRENT_METADATA') {
  416. this.state = 'HAVE_METADATA';
  417. }
  418. }
  419. /**
  420. * start loading of the playlist
  421. */
  422. }, {
  423. key: 'load',
  424. value: function load(isFinalRendition) {
  425. var _this4 = this;
  426. _globalWindow2['default'].clearTimeout(this.mediaUpdateTimeout);
  427. var media = this.media();
  428. if (isFinalRendition) {
  429. var delay = media ? media.targetDuration / 2 * 1000 : 5 * 1000;
  430. this.mediaUpdateTimeout = _globalWindow2['default'].setTimeout(function () {
  431. return _this4.load();
  432. }, delay);
  433. return;
  434. }
  435. if (!this.started) {
  436. this.start();
  437. return;
  438. }
  439. if (media && !media.endList) {
  440. this.trigger('mediaupdatetimeout');
  441. } else {
  442. this.trigger('loadedplaylist');
  443. }
  444. }
  445. /**
  446. * start loading of the playlist
  447. */
  448. }, {
  449. key: 'start',
  450. value: function start() {
  451. var _this5 = this;
  452. this.started = true;
  453. // request the specified URL
  454. this.request = this.hls_.xhr({
  455. uri: this.srcUrl,
  456. withCredentials: this.withCredentials
  457. }, function (error, req) {
  458. // disposed
  459. if (!_this5.request) {
  460. return;
  461. }
  462. // clear the loader's request reference
  463. _this5.request = null;
  464. if (error) {
  465. _this5.error = {
  466. status: req.status,
  467. message: 'HLS playlist request error at URL: ' + _this5.srcUrl,
  468. responseText: req.responseText,
  469. // MEDIA_ERR_NETWORK
  470. code: 2
  471. };
  472. if (_this5.state === 'HAVE_NOTHING') {
  473. _this5.started = false;
  474. }
  475. return _this5.trigger('error');
  476. }
  477. var parser = new _m3u8Parser2['default'].Parser();
  478. parser.push(req.responseText);
  479. parser.end();
  480. _this5.state = 'HAVE_MASTER';
  481. _this5.srcUrl = _this5.resolveManifestRedirect(_this5.srcUrl, req);
  482. parser.manifest.uri = _this5.srcUrl;
  483. // loaded a master playlist
  484. if (parser.manifest.playlists) {
  485. _this5.master = parser.manifest;
  486. setupMediaPlaylists(_this5.master);
  487. resolveMediaGroupUris(_this5.master);
  488. _this5.trigger('loadedplaylist');
  489. if (!_this5.request) {
  490. // no media playlist was specifically selected so start
  491. // from the first listed one
  492. _this5.media(parser.manifest.playlists[0]);
  493. }
  494. return;
  495. }
  496. // loaded a media playlist
  497. // infer a master playlist if none was previously requested
  498. _this5.master = {
  499. mediaGroups: {
  500. 'AUDIO': {},
  501. 'VIDEO': {},
  502. 'CLOSED-CAPTIONS': {},
  503. 'SUBTITLES': {}
  504. },
  505. uri: _globalWindow2['default'].location.href,
  506. playlists: [{
  507. uri: _this5.srcUrl,
  508. resolvedUri: _this5.srcUrl,
  509. // m3u8-parser does not attach an attributes property to media playlists so make
  510. // sure that the property is attached to avoid undefined reference errors
  511. attributes: {}
  512. }]
  513. };
  514. _this5.master.playlists[_this5.srcUrl] = _this5.master.playlists[0];
  515. _this5.haveMetadata(req, _this5.srcUrl);
  516. return _this5.trigger('loadedmetadata');
  517. });
  518. }
  519. }]);
  520. return PlaylistLoader;
  521. })(_videoJs.EventTarget);
  522. exports['default'] = PlaylistLoader;