@videojs/http-streaming
Version:
Play back HLS and DASH with Video.js, even where it's not natively supported
632 lines (543 loc) • 19.7 kB
JavaScript
/**
* @file playlist-loader.js
*
* A state machine that manages the loading, caching, and updating of
* M3U8 playlists.
*
*/
import { resolveUrl, resolveManifestRedirect } from './resolve-url';
import videojs from 'video.js';
import window from 'global/window';
import logger from './util/logger';
import {
parseManifest,
addPropertiesToMaster,
masterForMedia,
setupMediaPlaylist
} from './manifest';
const { mergeOptions, EventTarget } = videojs;
/**
* 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
*/
export const updateSegments = (original, update, offset) => {
const result = update.slice();
offset = offset || 0;
const length = Math.min(original.length, update.length + offset);
for (let i = offset; i < length; i++) {
result[i - offset] = mergeOptions(original[i], result[i - offset]);
}
return result;
};
export const 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.
*/
export const updateMaster = (master, media) => {
const result = mergeOptions(master, {});
const 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;
}
const 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((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 (let 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;
};
/**
* 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
*/
export const refreshDelay = (media, update) => {
const lastSegment = media.segments[media.segments.length - 1];
let delay;
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|Object} src url or object of manifest
* @param {boolean} withCredentials the withCredentials xhr option
* @class
*/
export default class PlaylistLoader extends EventTarget {
constructor(src, vhs, options = { }) {
super();
if (!src) {
throw new Error('A non-empty playlist URL or object is required');
}
this.logger_ = logger('PlaylistLoader');
const { withCredentials = false, handleManifestRedirects = false } = options;
this.src = src;
this.vhs_ = vhs;
this.withCredentials = withCredentials;
this.handleManifestRedirects = handleManifestRedirects;
const vhsOptions = vhs.options_;
this.customTagParsers = (vhsOptions && vhsOptions.customTagParsers) || [];
this.customTagMappers = (vhsOptions && vhsOptions.customTagMappers) || [];
// initialize the loader state
this.state = 'HAVE_NOTHING';
// live playlist staleness timeout
this.on('mediaupdatetimeout', () => {
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.vhs_.xhr({
uri: resolveUrl(this.master.uri, this.media().uri),
withCredentials: this.withCredentials
}, (error, req) => {
// disposed
if (!this.request) {
return;
}
if (error) {
return this.playlistRequestError(this.request, this.media(), 'HAVE_METADATA');
}
this.haveMetadata({
playlistString: this.request.responseText,
url: this.media().uri,
id: this.media().id
});
});
});
}
playlistRequestError(xhr, playlist, startingState) {
const {
uri,
id
} = playlist;
// 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.
*
* @param {string} [playlistString]
* Playlist string (if playlistObject is not provided)
* @param {Object} [playlistObject]
* Playlist object (if playlistString is not provided)
* @param {string} url
* URL of playlist
* @param {string} id
* ID to use for playlist
*/
haveMetadata({ playlistString, playlistObject, url, id }) {
// any in-flight request is now finished
this.request = null;
this.state = 'HAVE_METADATA';
const playlist = playlistObject || parseManifest({
onwarn: ({message}) => this.logger_(`m3u8-parser warn for ${id}: ${message}`),
oninfo: ({message}) => this.logger_(`m3u8-parser info for ${id}: ${message}`),
manifestString: playlistString,
customTagParsers: this.customTagParsers,
customTagMappers: this.customTagMappers
});
playlist.lastRequest = Date.now();
setupMediaPlaylist({
playlist,
uri: url,
id
});
// merge this playlist into the master
const update = updateMaster(this.master, playlist);
this.targetDuration = playlist.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.clearTimeout(this.mediaUpdateTimeout);
this.mediaUpdateTimeout = window.setTimeout(() => {
this.trigger('mediaupdatetimeout');
}, refreshDelay(this.media(), !!update));
}
this.trigger('loadedplaylist');
}
/**
* Abort any outstanding work and clean up.
*/
dispose() {
this.trigger('dispose');
this.stopRequest();
window.clearTimeout(this.mediaUpdateTimeout);
window.clearTimeout(this.finalRenditionTimeout);
this.off();
}
stopRequest() {
if (this.request) {
const 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=} shouldDelay whether we should delay the request by half target duration
*
* @return {Playlist} the current loaded media
*/
media(playlist, shouldDelay) {
// 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.clearTimeout(this.finalRenditionTimeout);
if (shouldDelay) {
const delay = (playlist.targetDuration / 2) * 1000 || 5 * 1000;
this.finalRenditionTimeout =
window.setTimeout(this.media.bind(this, playlist, false), delay);
return;
}
const startingState = this.state;
const mediaChange = !this.media_ || playlist.id !== this.media_.id;
// switch to fully loaded playlists immediately
if (this.master.playlists[playlist.id].endList ||
// handle the case of a playlist object (e.g., if using vhs-json with a resolved
// media playlist or, for the case of demuxed audio, a resolved audio media group)
(playlist.endList && playlist.segments.length)) {
// 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');
if (startingState === 'HAVE_MASTER') {
// The initial playlist was a master manifest, and the first media selected was
// also provided (in the form of a resolved playlist object) as part of the
// source object (rather than just a URL). Therefore, since the media playlist
// doesn't need to be requested, loadedmetadata won't trigger as part of the
// normal flow, and needs an explicit trigger here.
this.trigger('loadedmetadata');
} else {
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.vhs_.xhr({
uri: playlist.resolvedUri,
withCredentials: this.withCredentials
}, (error, req) => {
// disposed
if (!this.request) {
return;
}
playlist.lastRequest = Date.now();
playlist.resolvedUri = resolveManifestRedirect(this.handleManifestRedirects, playlist.resolvedUri, req);
if (error) {
return this.playlistRequestError(this.request, playlist, startingState);
}
this.haveMetadata({
playlistString: req.responseText,
url: playlist.uri,
id: playlist.id
});
// fire loadedmetadata the first time a media playlist is loaded
if (startingState === 'HAVE_MASTER') {
this.trigger('loadedmetadata');
} else {
this.trigger('mediachange');
}
});
}
/**
* pause loading of the playlist
*/
pause() {
this.stopRequest();
window.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
*/
load(shouldDelay) {
window.clearTimeout(this.mediaUpdateTimeout);
const media = this.media();
if (shouldDelay) {
const delay = media ? (media.targetDuration / 2) * 1000 : 5 * 1000;
this.mediaUpdateTimeout = window.setTimeout(() => this.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
*/
start() {
this.started = true;
if (typeof this.src === 'object') {
// in the case of an entirely constructed manifest object (meaning there's no actual
// manifest on a server), default the uri to the page's href
if (!this.src.uri) {
this.src.uri = window.location.href;
}
// resolvedUri is added on internally after the initial request. Since there's no
// request for pre-resolved manifests, add on resolvedUri here.
this.src.resolvedUri = this.src.uri;
// Since a manifest object was passed in as the source (instead of a URL), the first
// request can be skipped (since the top level of the manifest, at a minimum, is
// already available as a parsed manifest object). However, if the manifest object
// represents a master playlist, some media playlists may need to be resolved before
// the starting segment list is available. Therefore, go directly to setup of the
// initial playlist, and let the normal flow continue from there.
//
// Note that the call to setup is asynchronous, as other sections of VHS may assume
// that the first request is asynchronous.
setTimeout(() => {
this.setupInitialPlaylist(this.src);
}, 0);
return;
}
// request the specified URL
this.request = this.vhs_.xhr({
uri: this.src,
withCredentials: this.withCredentials
}, (error, req) => {
// disposed
if (!this.request) {
return;
}
// clear the loader's request reference
this.request = null;
if (error) {
this.error = {
status: req.status,
message: `HLS playlist request error at URL: ${this.src}.`,
responseText: req.responseText,
// MEDIA_ERR_NETWORK
code: 2
};
if (this.state === 'HAVE_NOTHING') {
this.started = false;
}
return this.trigger('error');
}
this.src = resolveManifestRedirect(this.handleManifestRedirects, this.src, req);
const manifest = parseManifest({
manifestString: req.responseText,
customTagParsers: this.customTagParsers,
customTagMappers: this.customTagMappers
});
this.setupInitialPlaylist(manifest);
});
}
srcUri() {
return typeof this.src === 'string' ? this.src : this.src.uri;
}
/**
* Given a manifest object that's either a master or media playlist, trigger the proper
* events and set the state of the playlist loader.
*
* If the manifest object represents a master playlist, `loadedplaylist` will be
* triggered to allow listeners to select a playlist. If none is selected, the loader
* will default to the first one in the playlists array.
*
* If the manifest object represents a media playlist, `loadedplaylist` will be
* triggered followed by `loadedmetadata`, as the only available playlist is loaded.
*
* In the case of a media playlist, a master playlist object wrapper with one playlist
* will be created so that all logic can handle playlists in the same fashion (as an
* assumed manifest object schema).
*
* @param {Object} manifest
* The parsed manifest object
*/
setupInitialPlaylist(manifest) {
this.state = 'HAVE_MASTER';
if (manifest.playlists) {
this.master = manifest;
addPropertiesToMaster(this.master, this.srcUri());
// If the initial master playlist has playlists wtih segments already resolved,
// then resolve URIs in advance, as they are usually done after a playlist request,
// which may not happen if the playlist is resolved.
manifest.playlists.forEach((playlist) => {
if (playlist.segments) {
playlist.segments.forEach((segment) => {
resolveSegmentUris(segment, playlist.resolvedUri);
});
}
});
this.trigger('loadedplaylist');
if (!this.request) {
// no media playlist was specifically selected so start
// from the first listed one
this.media(this.master.playlists[0]);
}
return;
}
// In order to support media playlists passed in as vhs-json, the case where the uri
// is not provided as part of the manifest should be considered, and an appropriate
// default used.
const uri = this.srcUri() || window.location.href;
this.master = masterForMedia(manifest, uri);
this.haveMetadata({
playlistObject: manifest,
url: uri,
id: this.master.playlists[0].id
});
this.trigger('loadedmetadata');
}
}