UNPKG

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