/**
* @file playlist-loader.js
*
* A state machine that manages the loading, caching, and updating of
* M3U8 playlists.
*
*/
import resolveUrl from './resolve-url';
import {mergeOptions} from 'video.js';
import Stream from './stream';
import m3u8 from 'm3u8-parser';
import window from 'global/window';
/**
* 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
*/
const updateSegments = function(original, update, offset) {
let result = update.slice();
let length;
let i;
offset = offset || 0;
length = Math.min(original.length, update.length + offset);
for (i = offset; i < length; i++) {
result[i - offset] = mergeOptions(original[i], result[i - offset]);
}
return result;
};
/**
* 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.
*/
const updateMaster = function(master, media) {
let changed = false;
let result = mergeOptions(master, {});
let i = master.playlists.length;
let playlist;
let segment;
let j;
while (i--) {
playlist = result.playlists[i];
if (playlist.uri === media.uri) {
// consider the playlist unchanged if the number of segments
// are equal and the media sequence number is unchanged
if (playlist.segments &&
media.segments &&
playlist.segments.length === media.segments.length &&
playlist.mediaSequence === media.mediaSequence) {
continue;
}
result.playlists[i] = mergeOptions(playlist, media);
result.playlists[media.uri] = result.playlists[i];
// if the update could overlap existing segment information,
// merge the two lists
if (playlist.segments) {
result.playlists[i].segments = updateSegments(
playlist.segments,
media.segments,
media.mediaSequence - playlist.mediaSequence
);
}
// resolve any missing segment and key URIs
j = 0;
if (result.playlists[i].segments) {
j = result.playlists[i].segments.length;
}
while (j--) {
segment = result.playlists[i].segments[j];
if (!segment.resolvedUri) {
segment.resolvedUri = resolveUrl(playlist.resolvedUri, segment.uri);
}
if (segment.key && !segment.key.resolvedUri) {
segment.key.resolvedUri = resolveUrl(playlist.resolvedUri, segment.key.uri);
}
if (segment.map && !segment.map.resolvedUri) {
segment.map.resolvedUri = resolveUrl(playlist.resolvedUri, segment.map.uri);
}
}
changed = true;
}
}
return changed ? result : null;
};
/**
* Load a playlist from a remote loacation
*
* @class PlaylistLoader
* @extends Stream
* @param {String} srcUrl the url to start with
* @param {Boolean} withCredentials the withCredentials xhr option
* @constructor
*/
const PlaylistLoader = function(srcUrl, hls, withCredentials) {
/* eslint-disable consistent-this */
let loader = this;
/* eslint-enable consistent-this */
let dispose;
let mediaUpdateTimeout;
let request;
let playlistRequestError;
let haveMetadata;
PlaylistLoader.prototype.constructor.call(this);
this.hls_ = hls;
// a flag that disables "expired time"-tracking this setting has
// no effect when not playing a live stream
this.trackExpiredTime_ = false;
if (!srcUrl) {
throw new Error('A non-empty playlist URL is required');
}
playlistRequestError = function(xhr, url, startingState) {
loader.setBandwidth(request || xhr);
// any in-flight request is now finished
request = null;
if (startingState) {
loader.state = startingState;
}
loader.error = {
playlist: loader.master.playlists[url],
status: xhr.status,
message: 'HLS playlist request error at URL: ' + url,
responseText: xhr.responseText,
code: (xhr.status >= 500) ? 4 : 2
};
loader.trigger('error');
};
// update the playlist loader's state in response to a new or
// updated playlist.
haveMetadata = function(xhr, url) {
let parser;
let refreshDelay;
let update;
loader.setBandwidth(request || xhr);
// any in-flight request is now finished
request = null;
loader.state = 'HAVE_METADATA';
parser = new m3u8.Parser();
parser.push(xhr.responseText);
parser.end();
parser.manifest.uri = url;
// merge this playlist into the master
update = updateMaster(loader.master, parser.manifest);
refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
loader.targetDuration = parser.manifest.targetDuration;
if (update) {
loader.master = update;
loader.updateMediaPlaylist_(parser.manifest);
} else {
// if the playlist is unchanged since the last reload,
// try again after half the target duration
refreshDelay /= 2;
}
// refresh live playlists after a target duration passes
if (!loader.media().endList) {
window.clearTimeout(mediaUpdateTimeout);
mediaUpdateTimeout = window.setTimeout(function() {
loader.trigger('mediaupdatetimeout');
}, refreshDelay);
}
loader.trigger('loadedplaylist');
};
// initialize the loader state
loader.state = 'HAVE_NOTHING';
// track the time that has expired from the live window
// this allows the seekable start range to be calculated even if
// all segments with timing information have expired
this.expired_ = 0;
// capture the prototype dispose function
dispose = this.dispose;
/**
* Abort any outstanding work and clean up.
*/
loader.dispose = function() {
loader.stopRequest();
window.clearTimeout(mediaUpdateTimeout);
dispose.call(this);
};
loader.stopRequest = () => {
if (request) {
let oldRequest = request;
request = null;
oldRequest.onreadystatechange = null;
oldRequest.abort();
}
};
/**
* Returns the number of enabled playlists on the master playlist object
*
* @return {Number} number of eneabled playlists
*/
loader.enabledPlaylists_ = function() {
return loader.master.playlists.filter((element, index, array) => {
return !element.excludeUntil || element.excludeUntil <= Date.now();
}).length;
};
/**
* Returns whether the current playlist is the lowest rendition
*
* @return {Boolean} true if on lowest rendition
*/
loader.isLowestEnabledRendition_ = function() {
let media = loader.media();
if (!media || !media.attributes) {
return false;
}
let currentBandwidth = loader.media().attributes.BANDWIDTH || 0;
return !(loader.master.playlists.filter((element, index, array) => {
let enabled = typeof element.excludeUntil === 'undefined' ||
element.excludeUntil <= Date.now();
if (!enabled) {
return false;
}
let item = element.attributes.BANDWIDTH;
return item <= currentBandwidth;
}).length > 1);
};
/**
* 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=} playlis tthe parsed media playlist
* object to switch to
* @return {Playlist} the current loaded media
*/
loader.media = function(playlist) {
let startingState = loader.state;
let mediaChange;
// getter
if (!playlist) {
return loader.media_;
}
// setter
if (loader.state === 'HAVE_NOTHING') {
throw new Error('Cannot switch media playlist from ' + loader.state);
}
// find the playlist object if the target playlist has been
// specified by URI
if (typeof playlist === 'string') {
if (!loader.master.playlists[playlist]) {
throw new Error('Unknown playlist URI: ' + playlist);
}
playlist = loader.master.playlists[playlist];
}
mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri;
// switch to fully loaded playlists immediately
if (loader.master.playlists[playlist.uri].endList) {
// abort outstanding playlist requests
if (request) {
request.onreadystatechange = null;
request.abort();
request = null;
}
loader.state = 'HAVE_METADATA';
loader.media_ = playlist;
// trigger media change if the active media has been updated
if (mediaChange) {
loader.trigger('mediachanging');
loader.trigger('mediachange');
}
return;
}
// switching to the active playlist is a no-op
if (!mediaChange) {
return;
}
loader.state = 'SWITCHING_MEDIA';
// there is already an outstanding playlist request
if (request) {
if (resolveUrl(loader.master.uri, playlist.uri) === request.url) {
// requesting to switch to the same playlist multiple times
// has no effect after the first
return;
}
request.onreadystatechange = null;
request.abort();
request = null;
}
// request the new playlist
if (this.media_) {
this.trigger('mediachanging');
}
request = this.hls_.xhr({
uri: resolveUrl(loader.master.uri, playlist.uri),
withCredentials
}, function(error, req) {
// disposed
if (!request) {
return;
}
if (error) {
return playlistRequestError(request, playlist.uri, startingState);
}
haveMetadata(req, playlist.uri);
// fire loadedmetadata the first time a media playlist is loaded
if (startingState === 'HAVE_MASTER') {
loader.trigger('loadedmetadata');
} else {
loader.trigger('mediachange');
}
});
};
/**
* set the bandwidth on an xhr to the bandwidth on the playlist
*/
loader.setBandwidth = function(xhr) {
loader.bandwidth = xhr.bandwidth;
};
// In a live playlist, don't keep track of the expired time
// until HLS tells us that "first play" has commenced
loader.on('firstplay', function() {
this.trackExpiredTime_ = true;
});
// live playlist staleness timeout
loader.on('mediaupdatetimeout', function() {
if (loader.state !== 'HAVE_METADATA') {
// only refresh the media playlist if no other activity is going on
return;
}
loader.state = 'HAVE_CURRENT_METADATA';
request = this.hls_.xhr({
uri: resolveUrl(loader.master.uri, loader.media().uri),
withCredentials
}, function(error, req) {
// disposed
if (!request) {
return;
}
if (error) {
return playlistRequestError(request, loader.media().uri);
}
haveMetadata(request, loader.media().uri);
});
});
/**
* pause loading of the playlist
*/
loader.pause = () => {
loader.stopRequest();
window.clearTimeout(mediaUpdateTimeout);
};
/**
* start loading of the playlist
*/
loader.load = () => {
if (loader.started) {
if (!loader.media().endList) {
loader.trigger('mediaupdatetimeout');
} else {
loader.trigger('loadedplaylist');
}
} else {
loader.start();
}
};
/**
* start loading of the playlist
*/
loader.start = () => {
loader.started = true;
// request the specified URL
request = this.hls_.xhr({
uri: srcUrl,
withCredentials
}, function(error, req) {
let parser;
let playlist;
let i;
// disposed
if (!request) {
return;
}
// clear the loader's request reference
request = null;
if (error) {
loader.error = {
status: req.status,
message: 'HLS playlist request error at URL: ' + srcUrl,
responseText: req.responseText,
// MEDIA_ERR_NETWORK
code: 2
};
return loader.trigger('error');
}
parser = new m3u8.Parser();
parser.push(req.responseText);
parser.end();
loader.state = 'HAVE_MASTER';
parser.manifest.uri = srcUrl;
// loaded a master playlist
if (parser.manifest.playlists) {
loader.master = parser.manifest;
// setup by-URI lookups and resolve media playlist URIs
i = loader.master.playlists.length;
while (i--) {
playlist = loader.master.playlists[i];
loader.master.playlists[playlist.uri] = playlist;
playlist.resolvedUri = resolveUrl(loader.master.uri, playlist.uri);
}
// resolve any media group URIs
for (let groupKey in loader.master.mediaGroups.AUDIO) {
for (let labelKey in loader.master.mediaGroups.AUDIO[groupKey]) {
let alternateAudio = loader.master.mediaGroups.AUDIO[groupKey][labelKey];
if (alternateAudio.uri) {
alternateAudio.resolvedUri =
resolveUrl(loader.master.uri, alternateAudio.uri);
}
}
}
loader.trigger('loadedplaylist');
if (!request) {
// no media playlist was specifically selected so start
// from the first listed one
loader.media(parser.manifest.playlists[0]);
}
return;
}
// loaded a media playlist
// infer a master playlist if none was previously requested
loader.master = {
mediaGroups: {
'AUDIO': {},
'VIDEO': {},
'CLOSED-CAPTIONS': {},
'SUBTITLES': {}
},
uri: window.location.href,
playlists: [{
uri: srcUrl
}]
};
loader.master.playlists[srcUrl] = loader.master.playlists[0];
loader.master.playlists[0].resolvedUri = srcUrl;
haveMetadata(req, srcUrl);
return loader.trigger('loadedmetadata');
});
};
};
PlaylistLoader.prototype = new Stream();
/**
* Update the PlaylistLoader state to reflect the changes in an
* update to the current media playlist.
*
* @param {Object} update the updated media playlist object
*/
PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) {
let outdated;
let i;
let segment;
outdated = this.media_;
this.media_ = this.master.playlists[update.uri];
if (!outdated) {
return;
}
// don't track expired time until this flag is truthy
if (!this.trackExpiredTime_) {
return;
}
// if the update was the result of a rendition switch do not
// attempt to calculate expired_ since media-sequences need not
// correlate between renditions/variants
if (update.uri !== outdated.uri) {
return;
}
// try using precise timing from first segment of the updated
// playlist
if (update.segments.length) {
if (typeof update.segments[0].start !== 'undefined') {
this.expired_ = update.segments[0].start;
return;
} else if (typeof update.segments[0].end !== 'undefined') {
this.expired_ = update.segments[0].end - update.segments[0].duration;
return;
}
}
// calculate expired by walking the outdated playlist
i = update.mediaSequence - outdated.mediaSequence - 1;
for (; i >= 0; i--) {
segment = outdated.segments[i];
if (!segment) {
// we missed information on this segment completely between
// playlist updates so we'll have to take an educated guess
// once we begin buffering again, any error we introduce can
// be corrected
this.expired_ += outdated.targetDuration || 10;
continue;
}
if (typeof segment.end !== 'undefined') {
this.expired_ = segment.end;
return;
}
if (typeof segment.start !== 'undefined') {
this.expired_ = segment.start + segment.duration;
return;
}
this.expired_ += segment.duration;
}
};
export default PlaylistLoader;