1293 lines
40 KiB
JavaScript
1293 lines
40 KiB
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,
|
|
addPropertiesToMain,
|
|
mainForMedia,
|
|
setupMediaPlaylist,
|
|
forEachMediaGroup,
|
|
createPlaylistID,
|
|
groupID
|
|
} from './manifest';
|
|
import {getKnownPartCount} from './playlist.js';
|
|
import {merge} from './util/vjs-compat';
|
|
import DateRangesStorage from './util/date-ranges';
|
|
import { getStreamingNetworkErrorMetadata } from './error-codes.js';
|
|
|
|
const { EventTarget } = videojs;
|
|
|
|
const addLLHLSQueryDirectives = (uri, media) => {
|
|
if (media.endList || !media.serverControl) {
|
|
return uri;
|
|
}
|
|
|
|
const parameters = {};
|
|
|
|
if (media.serverControl.canBlockReload) {
|
|
const {preloadSegment} = media;
|
|
// next msn is a zero based value, length is not.
|
|
let nextMSN = media.mediaSequence + media.segments.length;
|
|
|
|
// If preload segment has parts then it is likely
|
|
// that we are going to request a part of that preload segment.
|
|
// the logic below is used to determine that.
|
|
if (preloadSegment) {
|
|
const parts = preloadSegment.parts || [];
|
|
// _HLS_part is a zero based index
|
|
const nextPart = getKnownPartCount(media) - 1;
|
|
|
|
// if nextPart is > -1 and not equal to just the
|
|
// length of parts, then we know we had part preload hints
|
|
// and we need to add the _HLS_part= query
|
|
if (nextPart > -1 && nextPart !== (parts.length - 1)) {
|
|
// add existing parts to our preload hints
|
|
// eslint-disable-next-line
|
|
parameters._HLS_part = nextPart;
|
|
}
|
|
|
|
// this if statement makes sure that we request the msn
|
|
// of the preload segment if:
|
|
// 1. the preload segment had parts (and was not yet a full segment)
|
|
// but was added to our segments array
|
|
// 2. the preload segment had preload hints for parts that are not in
|
|
// the manifest yet.
|
|
// in all other cases we want the segment after the preload segment
|
|
// which will be given by using media.segments.length because it is 1 based
|
|
// rather than 0 based.
|
|
if (nextPart > -1 || parts.length) {
|
|
nextMSN--;
|
|
}
|
|
}
|
|
|
|
// add _HLS_msn= in front of any _HLS_part query
|
|
// eslint-disable-next-line
|
|
parameters._HLS_msn = nextMSN;
|
|
}
|
|
|
|
if (media.serverControl && media.serverControl.canSkipUntil) {
|
|
// add _HLS_skip= infront of all other queries.
|
|
// eslint-disable-next-line
|
|
parameters._HLS_skip = (media.serverControl.canSkipDateranges ? 'v2' : 'YES');
|
|
}
|
|
|
|
if (Object.keys(parameters).length) {
|
|
const parsedUri = new window.URL(uri);
|
|
|
|
['_HLS_skip', '_HLS_msn', '_HLS_part'].forEach(function(name) {
|
|
if (!parameters.hasOwnProperty(name)) {
|
|
return;
|
|
}
|
|
|
|
parsedUri.searchParams.set(name, parameters[name]);
|
|
});
|
|
|
|
uri = parsedUri.toString();
|
|
}
|
|
|
|
return uri;
|
|
};
|
|
|
|
/**
|
|
* Returns a new segment object with properties and
|
|
* the parts array merged.
|
|
*
|
|
* @param {Object} a the old segment
|
|
* @param {Object} b the new segment
|
|
*
|
|
* @return {Object} the merged segment
|
|
*/
|
|
export const updateSegment = (a, b) => {
|
|
if (!a) {
|
|
return b;
|
|
}
|
|
|
|
const result = merge(a, b);
|
|
|
|
// if only the old segment has preload hints
|
|
// and the new one does not, remove preload hints.
|
|
if (a.preloadHints && !b.preloadHints) {
|
|
delete result.preloadHints;
|
|
}
|
|
|
|
// if only the old segment has parts
|
|
// then the parts are no longer valid
|
|
if (a.parts && !b.parts) {
|
|
delete result.parts;
|
|
// if both segments have parts
|
|
// copy part propeties from the old segment
|
|
// to the new one.
|
|
} else if (a.parts && b.parts) {
|
|
for (let i = 0; i < b.parts.length; i++) {
|
|
if (a.parts && a.parts[i]) {
|
|
result.parts[i] = merge(a.parts[i], b.parts[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// set skipped to false for segments that have
|
|
// have had information merged from the old segment.
|
|
if (!a.skipped && b.skipped) {
|
|
result.skipped = false;
|
|
}
|
|
|
|
// set preload to false for segments that have
|
|
// had information added in the new segment.
|
|
if (a.preload && !b.preload) {
|
|
result.preload = false;
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* 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 ovewritten.
|
|
*
|
|
* @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 {Array} a list of merged segment objects
|
|
*/
|
|
export const updateSegments = (original, update, offset) => {
|
|
const oldSegments = original.slice();
|
|
const newSegments = update.slice();
|
|
|
|
offset = offset || 0;
|
|
const result = [];
|
|
|
|
let currentMap;
|
|
|
|
for (let newIndex = 0; newIndex < newSegments.length; newIndex++) {
|
|
const oldSegment = oldSegments[newIndex + offset];
|
|
const newSegment = newSegments[newIndex];
|
|
|
|
if (oldSegment) {
|
|
currentMap = oldSegment.map || currentMap;
|
|
|
|
result.push(updateSegment(oldSegment, newSegment));
|
|
} else {
|
|
// carry over map to new segment if it is missing
|
|
if (currentMap && !newSegment.map) {
|
|
newSegment.map = currentMap;
|
|
}
|
|
|
|
result.push(newSegment);
|
|
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
export const resolveSegmentUris = (segment, baseUri) => {
|
|
// preloadSegment will not have a uri at all
|
|
// as the segment isn't actually in the manifest yet, only parts
|
|
if (!segment.resolvedUri && segment.uri) {
|
|
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);
|
|
}
|
|
|
|
if (segment.map && segment.map.key && !segment.map.key.resolvedUri) {
|
|
segment.map.key.resolvedUri = resolveUrl(baseUri, segment.map.key.uri);
|
|
}
|
|
if (segment.parts && segment.parts.length) {
|
|
segment.parts.forEach((p) => {
|
|
if (p.resolvedUri) {
|
|
return;
|
|
}
|
|
p.resolvedUri = resolveUrl(baseUri, p.uri);
|
|
});
|
|
}
|
|
|
|
if (segment.preloadHints && segment.preloadHints.length) {
|
|
segment.preloadHints.forEach((p) => {
|
|
if (p.resolvedUri) {
|
|
return;
|
|
}
|
|
p.resolvedUri = resolveUrl(baseUri, p.uri);
|
|
});
|
|
}
|
|
};
|
|
|
|
const getAllSegments = function(media) {
|
|
const segments = media.segments || [];
|
|
const preloadSegment = media.preloadSegment;
|
|
|
|
// a preloadSegment with only preloadHints is not currently
|
|
// a usable segment, only include a preloadSegment that has
|
|
// parts.
|
|
if (preloadSegment && preloadSegment.parts && preloadSegment.parts.length) {
|
|
// if preloadHints has a MAP that means that the
|
|
// init segment is going to change. We cannot use any of the parts
|
|
// from this preload segment.
|
|
if (preloadSegment.preloadHints) {
|
|
for (let i = 0; i < preloadSegment.preloadHints.length; i++) {
|
|
if (preloadSegment.preloadHints[i].type === 'MAP') {
|
|
return segments;
|
|
}
|
|
}
|
|
}
|
|
// set the duration for our preload segment to target duration.
|
|
preloadSegment.duration = media.targetDuration;
|
|
preloadSegment.preload = true;
|
|
|
|
segments.push(preloadSegment);
|
|
}
|
|
|
|
return segments;
|
|
};
|
|
|
|
// consider the playlist unchanged if the playlist object is the same or
|
|
// the number of segments is equal, the media sequence number is unchanged,
|
|
// and this playlist hasn't become the end of the playlist
|
|
export const isPlaylistUnchanged = (a, b) => a === b ||
|
|
(a.segments && b.segments && a.segments.length === b.segments.length &&
|
|
a.endList === b.endList &&
|
|
a.mediaSequence === b.mediaSequence &&
|
|
a.preloadSegment === b.preloadSegment);
|
|
|
|
/**
|
|
* Returns a new main 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 main playlist, null is returned.
|
|
*
|
|
* @param {Object} main a parsed main M3U8 object
|
|
* @param {Object} media a parsed media M3U8 object
|
|
* @return {Object} a new object that represents the original
|
|
* main playlist with the updated media playlist merged in, or
|
|
* null if the merge produced no change.
|
|
*/
|
|
export const updateMain = (main, newMedia, unchangedCheck = isPlaylistUnchanged) => {
|
|
const result = merge(main, {});
|
|
const oldMedia = result.playlists[newMedia.id];
|
|
|
|
if (!oldMedia) {
|
|
return null;
|
|
}
|
|
|
|
if (unchangedCheck(oldMedia, newMedia)) {
|
|
return null;
|
|
}
|
|
|
|
newMedia.segments = getAllSegments(newMedia);
|
|
|
|
const mergedPlaylist = merge(oldMedia, newMedia);
|
|
|
|
// always use the new media's preload segment
|
|
if (mergedPlaylist.preloadSegment && !newMedia.preloadSegment) {
|
|
delete mergedPlaylist.preloadSegment;
|
|
}
|
|
|
|
// if the update could overlap existing segment information, merge the two segment lists
|
|
if (oldMedia.segments) {
|
|
if (newMedia.skip) {
|
|
newMedia.segments = newMedia.segments || [];
|
|
// add back in objects for skipped segments, so that we merge
|
|
// old properties into the new segments
|
|
for (let i = 0; i < newMedia.skip.skippedSegments; i++) {
|
|
newMedia.segments.unshift({skipped: true});
|
|
}
|
|
}
|
|
mergedPlaylist.segments = updateSegments(
|
|
oldMedia.segments,
|
|
newMedia.segments,
|
|
newMedia.mediaSequence - oldMedia.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 === newMedia.id) {
|
|
result.playlists[i] = mergedPlaylist;
|
|
}
|
|
}
|
|
result.playlists[newMedia.id] = mergedPlaylist;
|
|
// URI reference added for backwards compatibility
|
|
result.playlists[newMedia.uri] = mergedPlaylist;
|
|
|
|
// update media group playlist references.
|
|
forEachMediaGroup(main, (properties, mediaType, groupKey, labelKey) => {
|
|
if (!properties.playlists) {
|
|
return;
|
|
}
|
|
for (let i = 0; i < properties.playlists.length; i++) {
|
|
if (newMedia.id === properties.playlists[i].id) {
|
|
properties.playlists[i] = 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 segments = media.segments || [];
|
|
const lastSegment = segments[segments.length - 1];
|
|
const lastPart = lastSegment && lastSegment.parts && lastSegment.parts[lastSegment.parts.length - 1];
|
|
const lastDuration = lastPart && lastPart.duration || lastSegment && lastSegment.duration;
|
|
|
|
if (update && lastDuration) {
|
|
return lastDuration * 1000;
|
|
}
|
|
|
|
// if the playlist is unchanged since the last reload or last segment duration
|
|
// cannot be determined, try again after half the target duration
|
|
return (media.partTargetDuration || media.targetDuration || 10) * 500;
|
|
};
|
|
|
|
const playlistMetadataPayload = (playlists, type, isLive) => {
|
|
if (!playlists) {
|
|
return;
|
|
}
|
|
const renditions = [];
|
|
|
|
playlists.forEach((playlist) => {
|
|
// we need attributes to populate rendition data.
|
|
if (!playlist.attributes) {
|
|
return;
|
|
}
|
|
const { BANDWIDTH, RESOLUTION, CODECS } = playlist.attributes;
|
|
|
|
renditions.push({
|
|
id: playlist.id,
|
|
bandwidth: BANDWIDTH,
|
|
resolution: RESOLUTION,
|
|
codecs: CODECS
|
|
});
|
|
});
|
|
|
|
return {
|
|
type,
|
|
isLive,
|
|
renditions
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 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} = options;
|
|
|
|
this.src = src;
|
|
this.vhs_ = vhs;
|
|
this.withCredentials = withCredentials;
|
|
this.addDateRangesToTextTrack_ = options.addDateRangesToTextTrack;
|
|
|
|
const vhsOptions = vhs.options_;
|
|
|
|
this.customTagParsers = (vhsOptions && vhsOptions.customTagParsers) || [];
|
|
this.customTagMappers = (vhsOptions && vhsOptions.customTagMappers) || [];
|
|
this.llhls = vhsOptions && vhsOptions.llhls;
|
|
this.dateRangesStorage_ = new DateRangesStorage();
|
|
|
|
// initialize the loader state
|
|
this.state = 'HAVE_NOTHING';
|
|
|
|
// live playlist staleness timeout
|
|
this.handleMediaupdatetimeout_ = this.handleMediaupdatetimeout_.bind(this);
|
|
this.on('mediaupdatetimeout', this.handleMediaupdatetimeout_);
|
|
this.on('loadedplaylist', this.handleLoadedPlaylist_.bind(this));
|
|
}
|
|
|
|
handleLoadedPlaylist_() {
|
|
const mediaPlaylist = this.media();
|
|
|
|
if (!mediaPlaylist) {
|
|
return;
|
|
}
|
|
|
|
this.dateRangesStorage_.setOffset(mediaPlaylist.segments);
|
|
this.dateRangesStorage_.setPendingDateRanges(mediaPlaylist.dateRanges);
|
|
const availableDateRanges = this.dateRangesStorage_.getDateRangesToProcess();
|
|
|
|
if (!availableDateRanges.length || !this.addDateRangesToTextTrack_) {
|
|
return;
|
|
}
|
|
|
|
this.addDateRangesToTextTrack_(availableDateRanges);
|
|
}
|
|
|
|
handleMediaupdatetimeout_() {
|
|
if (this.state !== 'HAVE_METADATA') {
|
|
// only refresh the media playlist if no other activity is going on
|
|
return;
|
|
}
|
|
const media = this.media();
|
|
|
|
let uri = resolveUrl(this.main.uri, media.uri);
|
|
|
|
if (this.llhls) {
|
|
uri = addLLHLSQueryDirectives(uri, media);
|
|
}
|
|
this.state = 'HAVE_CURRENT_METADATA';
|
|
|
|
this.request = this.vhs_.xhr({
|
|
uri,
|
|
withCredentials: this.withCredentials,
|
|
requestType: 'hls-playlist'
|
|
}, (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.main.playlists[id],
|
|
status: xhr.status,
|
|
message: `HLS playlist request error at URL: ${uri}.`,
|
|
responseText: xhr.responseText,
|
|
code: (xhr.status >= 500) ? 4 : 2,
|
|
metadata: getStreamingNetworkErrorMetadata({ requestType: xhr.requestType, request: xhr, error: xhr.error })
|
|
};
|
|
|
|
this.trigger('error');
|
|
}
|
|
|
|
parseManifest_({url, manifestString}) {
|
|
try {
|
|
return parseManifest({
|
|
onwarn: ({message}) => this.logger_(`m3u8-parser warn for ${url}: ${message}`),
|
|
oninfo: ({message}) => this.logger_(`m3u8-parser info for ${url}: ${message}`),
|
|
manifestString,
|
|
customTagParsers: this.customTagParsers,
|
|
customTagMappers: this.customTagMappers,
|
|
llhls: this.llhls
|
|
});
|
|
} catch (error) {
|
|
this.error = error;
|
|
this.error.metadata = {
|
|
errorType: videojs.Error.StreamingHlsPlaylistParserError,
|
|
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 metadata = {
|
|
playlistInfo: {
|
|
type: 'media',
|
|
uri: url
|
|
}
|
|
};
|
|
|
|
this.trigger({type: 'playlistparsestart', metadata });
|
|
const playlist = playlistObject || this.parseManifest_({
|
|
url,
|
|
manifestString: playlistString
|
|
});
|
|
|
|
playlist.lastRequest = Date.now();
|
|
|
|
setupMediaPlaylist({
|
|
playlist,
|
|
uri: url,
|
|
id
|
|
});
|
|
|
|
// merge this playlist into the main manifest
|
|
const update = updateMain(this.main, playlist);
|
|
|
|
this.targetDuration = playlist.partTargetDuration || playlist.targetDuration;
|
|
|
|
this.pendingMedia_ = null;
|
|
|
|
if (update) {
|
|
this.main = update;
|
|
this.media_ = this.main.playlists[id];
|
|
} else {
|
|
this.trigger('playlistunchanged');
|
|
}
|
|
|
|
this.updateMediaUpdateTimeout_(refreshDelay(this.media(), !!update));
|
|
metadata.parsedPlaylist = playlistMetadataPayload(this.main.playlists, metadata.playlistInfo.type, !this.media_.endList);
|
|
this.trigger({ type: 'playlistparsecomplete', metadata });
|
|
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.dateRangesStorage_ = new DateRangesStorage();
|
|
|
|
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.main.playlists[playlist]) {
|
|
throw new Error('Unknown playlist URI: ' + playlist);
|
|
}
|
|
playlist = this.main.playlists[playlist];
|
|
}
|
|
|
|
window.clearTimeout(this.finalRenditionTimeout);
|
|
|
|
if (shouldDelay) {
|
|
const delay = ((playlist.partTargetDuration || 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;
|
|
const mainPlaylistRef = this.main.playlists[playlist.id];
|
|
|
|
// switch to fully loaded playlists immediately
|
|
if (mainPlaylistRef && mainPlaylistRef.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_MAIN_MANIFEST') {
|
|
// The initial playlist was a main 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;
|
|
}
|
|
|
|
// We update/set the timeout here so that live playlists
|
|
// that are not a media change will "start" the loader as expected.
|
|
// We expect that this function will start the media update timeout
|
|
// cycle again. This also prevents a playlist switch failure from
|
|
// causing us to stall during live.
|
|
this.updateMediaUpdateTimeout_(refreshDelay(playlist, true));
|
|
|
|
// 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.pendingMedia_ = playlist;
|
|
const metadata = {
|
|
playlistInfo: {
|
|
type: 'media',
|
|
uri: playlist.uri
|
|
}
|
|
};
|
|
|
|
this.trigger({ type: 'playlistrequeststart', metadata });
|
|
|
|
this.request = this.vhs_.xhr({
|
|
uri: playlist.resolvedUri,
|
|
withCredentials: this.withCredentials,
|
|
requestType: 'hls-playlist'
|
|
}, (error, req) => {
|
|
// disposed
|
|
if (!this.request) {
|
|
return;
|
|
}
|
|
|
|
playlist.lastRequest = Date.now();
|
|
|
|
playlist.resolvedUri = resolveManifestRedirect(playlist.resolvedUri, req);
|
|
|
|
if (error) {
|
|
return this.playlistRequestError(this.request, playlist, startingState);
|
|
}
|
|
|
|
this.trigger({ type: 'playlistrequestcomplete', metadata });
|
|
|
|
this.haveMetadata({
|
|
playlistString: req.responseText,
|
|
url: playlist.uri,
|
|
id: playlist.id
|
|
});
|
|
|
|
// fire loadedmetadata the first time a media playlist is loaded
|
|
if (startingState === 'HAVE_MAIN_MANIFEST') {
|
|
this.trigger('loadedmetadata');
|
|
} else {
|
|
this.trigger('mediachange');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* pause loading of the playlist
|
|
*/
|
|
pause() {
|
|
if (this.mediaUpdateTimeout) {
|
|
window.clearTimeout(this.mediaUpdateTimeout);
|
|
this.mediaUpdateTimeout = null;
|
|
}
|
|
|
|
this.stopRequest();
|
|
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_MAIN_MANIFEST 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_MAIN_MANIFEST';
|
|
}
|
|
} else if (this.state === 'HAVE_CURRENT_METADATA') {
|
|
this.state = 'HAVE_METADATA';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* start loading of the playlist
|
|
*/
|
|
load(shouldDelay) {
|
|
if (this.mediaUpdateTimeout) {
|
|
window.clearTimeout(this.mediaUpdateTimeout);
|
|
this.mediaUpdateTimeout = null;
|
|
}
|
|
const media = this.media();
|
|
|
|
if (shouldDelay) {
|
|
const delay = media ? ((media.partTargetDuration || media.targetDuration) / 2) * 1000 : 5 * 1000;
|
|
|
|
this.mediaUpdateTimeout = window.setTimeout(() => {
|
|
this.mediaUpdateTimeout = null;
|
|
this.load();
|
|
}, delay);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!this.started) {
|
|
this.start();
|
|
return;
|
|
}
|
|
|
|
if (media && !media.endList) {
|
|
this.trigger('mediaupdatetimeout');
|
|
} else {
|
|
this.trigger('loadedplaylist');
|
|
}
|
|
}
|
|
|
|
updateMediaUpdateTimeout_(delay) {
|
|
if (this.mediaUpdateTimeout) {
|
|
window.clearTimeout(this.mediaUpdateTimeout);
|
|
this.mediaUpdateTimeout = null;
|
|
}
|
|
|
|
// we only have use mediaupdatetimeout for live playlists.
|
|
if (!this.media() || this.media().endList) {
|
|
return;
|
|
}
|
|
|
|
this.mediaUpdateTimeout = window.setTimeout(() => {
|
|
this.mediaUpdateTimeout = null;
|
|
this.trigger('mediaupdatetimeout');
|
|
this.updateMediaUpdateTimeout_(delay);
|
|
}, delay);
|
|
}
|
|
|
|
/**
|
|
* 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 main 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;
|
|
}
|
|
const metadata = {
|
|
playlistInfo: {
|
|
type: 'multivariant',
|
|
uri: this.src
|
|
}
|
|
};
|
|
|
|
this.trigger({ type: 'playlistrequeststart', metadata });
|
|
// request the specified URL
|
|
this.request = this.vhs_.xhr({
|
|
uri: this.src,
|
|
withCredentials: this.withCredentials,
|
|
requestType: 'hls-playlist'
|
|
}, (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,
|
|
metadata: getStreamingNetworkErrorMetadata({ requestType: req.requestType, request: req, error })
|
|
};
|
|
if (this.state === 'HAVE_NOTHING') {
|
|
this.started = false;
|
|
}
|
|
return this.trigger('error');
|
|
}
|
|
this.trigger({ type: 'playlistrequestcomplete', metadata });
|
|
|
|
this.src = resolveManifestRedirect(this.src, req);
|
|
|
|
this.trigger({ type: 'playlistparsestart', metadata });
|
|
const manifest = this.parseManifest_({
|
|
manifestString: req.responseText,
|
|
url: this.src
|
|
});
|
|
|
|
// we haven't loaded any variant playlists here so we default to false for isLive.
|
|
metadata.parsedPlaylist = playlistMetadataPayload(manifest.playlists, metadata.playlistInfo.type, false);
|
|
this.trigger({ type: 'playlistparsecomplete', metadata });
|
|
|
|
this.setupInitialPlaylist(manifest);
|
|
});
|
|
}
|
|
|
|
srcUri() {
|
|
return typeof this.src === 'string' ? this.src : this.src.uri;
|
|
}
|
|
|
|
/**
|
|
* Given a manifest object that's either a main or media playlist, trigger the proper
|
|
* events and set the state of the playlist loader.
|
|
*
|
|
* If the manifest object represents a main 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 main 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_MAIN_MANIFEST';
|
|
|
|
if (manifest.playlists) {
|
|
this.main = manifest;
|
|
addPropertiesToMain(this.main, this.srcUri());
|
|
// If the initial main 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) => {
|
|
playlist.segments = getAllSegments(playlist);
|
|
|
|
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.main.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.main = mainForMedia(manifest, uri);
|
|
this.haveMetadata({
|
|
playlistObject: manifest,
|
|
url: uri,
|
|
id: this.main.playlists[0].id
|
|
});
|
|
this.trigger('loadedmetadata');
|
|
}
|
|
|
|
/**
|
|
* Updates or deletes a preexisting pathway clone.
|
|
* Ensures that all playlists related to the old pathway clone are
|
|
* either updated or deleted.
|
|
*
|
|
* @param {Object} clone On update, the pathway clone object for the newly updated pathway clone.
|
|
* On delete, the old pathway clone object to be deleted.
|
|
* @param {boolean} isUpdate True if the pathway is to be updated,
|
|
* false if it is meant to be deleted.
|
|
*/
|
|
updateOrDeleteClone(clone, isUpdate) {
|
|
const main = this.main;
|
|
const pathway = clone.ID;
|
|
|
|
let i = main.playlists.length;
|
|
|
|
// Iterate backwards through the playlist so we can remove playlists if necessary.
|
|
while (i--) {
|
|
const p = main.playlists[i];
|
|
|
|
if (p.attributes['PATHWAY-ID'] === pathway) {
|
|
const oldPlaylistUri = p.resolvedUri;
|
|
const oldPlaylistId = p.id;
|
|
|
|
// update the indexed playlist and add new playlists by ID and URI
|
|
if (isUpdate) {
|
|
const newPlaylistUri = this.createCloneURI_(p.resolvedUri, clone);
|
|
const newPlaylistId = createPlaylistID(pathway, newPlaylistUri);
|
|
const attributes = this.createCloneAttributes_(pathway, p.attributes);
|
|
const updatedPlaylist = this.createClonePlaylist_(p, newPlaylistId, clone, attributes);
|
|
|
|
main.playlists[i] = updatedPlaylist;
|
|
main.playlists[newPlaylistId] = updatedPlaylist;
|
|
main.playlists[newPlaylistUri] = updatedPlaylist;
|
|
} else {
|
|
// Remove the indexed playlist.
|
|
main.playlists.splice(i, 1);
|
|
}
|
|
|
|
// Remove playlists by the old ID and URI.
|
|
delete main.playlists[oldPlaylistId];
|
|
delete main.playlists[oldPlaylistUri];
|
|
}
|
|
}
|
|
|
|
this.updateOrDeleteCloneMedia(clone, isUpdate);
|
|
}
|
|
|
|
/**
|
|
* Updates or deletes media data based on the pathway clone object.
|
|
* Due to the complexity of the media groups and playlists, in all cases
|
|
* we remove all of the old media groups and playlists.
|
|
* On updates, we then create new media groups and playlists based on the
|
|
* new pathway clone object.
|
|
*
|
|
* @param {Object} clone The pathway clone object for the newly updated pathway clone.
|
|
* @param {boolean} isUpdate True if the pathway is to be updated,
|
|
* false if it is meant to be deleted.
|
|
*/
|
|
updateOrDeleteCloneMedia(clone, isUpdate) {
|
|
const main = this.main;
|
|
const id = clone.ID;
|
|
|
|
['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((mediaType) => {
|
|
if (!main.mediaGroups[mediaType] || !main.mediaGroups[mediaType][id]) {
|
|
return;
|
|
}
|
|
|
|
for (const groupKey in main.mediaGroups[mediaType]) {
|
|
// Remove all media playlists for the media group for this pathway clone.
|
|
if (groupKey === id) {
|
|
for (const labelKey in main.mediaGroups[mediaType][groupKey]) {
|
|
const oldMedia = main.mediaGroups[mediaType][groupKey][labelKey];
|
|
|
|
oldMedia.playlists.forEach((p, i) => {
|
|
const oldMediaPlaylist = main.playlists[p.id];
|
|
const oldPlaylistId = oldMediaPlaylist.id;
|
|
const oldPlaylistUri = oldMediaPlaylist.resolvedUri;
|
|
|
|
delete main.playlists[oldPlaylistId];
|
|
delete main.playlists[oldPlaylistUri];
|
|
});
|
|
}
|
|
|
|
// Delete the old media group.
|
|
delete main.mediaGroups[mediaType][groupKey];
|
|
}
|
|
}
|
|
});
|
|
|
|
// Create the new media groups and playlists if there is an update.
|
|
if (isUpdate) {
|
|
this.createClonedMediaGroups_(clone);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Given a pathway clone object, clones all necessary playlists.
|
|
*
|
|
* @param {Object} clone The pathway clone object.
|
|
* @param {Object} basePlaylist The original playlist to clone from.
|
|
*/
|
|
addClonePathway(clone, basePlaylist = {}) {
|
|
const main = this.main;
|
|
const index = main.playlists.length;
|
|
const uri = this.createCloneURI_(basePlaylist.resolvedUri, clone);
|
|
const playlistId = createPlaylistID(clone.ID, uri);
|
|
const attributes = this.createCloneAttributes_(clone.ID, basePlaylist.attributes);
|
|
|
|
const playlist = this.createClonePlaylist_(basePlaylist, playlistId, clone, attributes);
|
|
|
|
main.playlists[index] = playlist;
|
|
|
|
// add playlist by ID and URI
|
|
main.playlists[playlistId] = playlist;
|
|
main.playlists[uri] = playlist;
|
|
|
|
this.createClonedMediaGroups_(clone);
|
|
}
|
|
|
|
/**
|
|
* Given a pathway clone object we create clones of all media.
|
|
* In this function, all necessary information and updated playlists
|
|
* are added to the `mediaGroup` object.
|
|
* Playlists are also added to the `playlists` array so the media groups
|
|
* will be properly linked.
|
|
*
|
|
* @param {Object} clone The pathway clone object.
|
|
*/
|
|
createClonedMediaGroups_(clone) {
|
|
const id = clone.ID;
|
|
const baseID = clone['BASE-ID'];
|
|
const main = this.main;
|
|
|
|
['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((mediaType) => {
|
|
// If the media type doesn't exist, or there is already a clone, skip
|
|
// to the next media type.
|
|
if (!main.mediaGroups[mediaType] || main.mediaGroups[mediaType][id]) {
|
|
return;
|
|
}
|
|
|
|
for (const groupKey in main.mediaGroups[mediaType]) {
|
|
if (groupKey === baseID) {
|
|
// Create the group.
|
|
main.mediaGroups[mediaType][id] = {};
|
|
} else {
|
|
// There is no need to iterate over label keys in this case.
|
|
continue;
|
|
}
|
|
|
|
for (const labelKey in main.mediaGroups[mediaType][groupKey]) {
|
|
const oldMedia = main.mediaGroups[mediaType][groupKey][labelKey];
|
|
|
|
main.mediaGroups[mediaType][id][labelKey] = Object.assign({}, oldMedia);
|
|
const newMedia = main.mediaGroups[mediaType][id][labelKey];
|
|
|
|
// update URIs on the media
|
|
const newUri = this.createCloneURI_(oldMedia.resolvedUri, clone);
|
|
|
|
newMedia.resolvedUri = newUri;
|
|
newMedia.uri = newUri;
|
|
|
|
// Reset playlists in the new media group.
|
|
newMedia.playlists = [];
|
|
|
|
// Create new playlists in the newly cloned media group.
|
|
oldMedia.playlists.forEach((p, i) => {
|
|
const oldMediaPlaylist = main.playlists[p.id];
|
|
const group = groupID(mediaType, id, labelKey);
|
|
const newPlaylistID = createPlaylistID(id, group);
|
|
|
|
// Check to see if it already exists
|
|
if (oldMediaPlaylist && !main.playlists[newPlaylistID]) {
|
|
const newMediaPlaylist = this.createClonePlaylist_(oldMediaPlaylist, newPlaylistID, clone);
|
|
|
|
const newPlaylistUri = newMediaPlaylist.resolvedUri;
|
|
|
|
main.playlists[newPlaylistID] = newMediaPlaylist;
|
|
main.playlists[newPlaylistUri] = newMediaPlaylist;
|
|
}
|
|
|
|
newMedia.playlists[i] = this.createClonePlaylist_(p, newPlaylistID, clone);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Using the original playlist to be cloned, and the pathway clone object
|
|
* information, we create a new playlist.
|
|
*
|
|
* @param {Object} basePlaylist The original playlist to be cloned from.
|
|
* @param {string} id The desired id of the newly cloned playlist.
|
|
* @param {Object} clone The pathway clone object.
|
|
* @param {Object} attributes An optional object to populate the `attributes` property in the playlist.
|
|
*
|
|
* @return {Object} The combined cloned playlist.
|
|
*/
|
|
createClonePlaylist_(basePlaylist, id, clone, attributes) {
|
|
const uri = this.createCloneURI_(basePlaylist.resolvedUri, clone);
|
|
const newProps = {
|
|
resolvedUri: uri,
|
|
uri,
|
|
id
|
|
};
|
|
|
|
// Remove all segments from previous playlist in the clone.
|
|
if (basePlaylist.segments) {
|
|
newProps.segments = [];
|
|
}
|
|
|
|
if (attributes) {
|
|
newProps.attributes = attributes;
|
|
}
|
|
|
|
return merge(basePlaylist, newProps);
|
|
}
|
|
|
|
/**
|
|
* Generates an updated URI for a cloned pathway based on the original
|
|
* pathway's URI and the paramaters from the pathway clone object in the
|
|
* content steering server response.
|
|
*
|
|
* @param {string} baseUri URI to be updated in the cloned pathway.
|
|
* @param {Object} clone The pathway clone object.
|
|
*
|
|
* @return {string} The updated URI for the cloned pathway.
|
|
*/
|
|
createCloneURI_(baseURI, clone) {
|
|
const uri = new URL(baseURI);
|
|
|
|
uri.hostname = clone['URI-REPLACEMENT'].HOST;
|
|
|
|
const params = clone['URI-REPLACEMENT'].PARAMS;
|
|
|
|
// Add params to the cloned URL.
|
|
for (const key of Object.keys(params)) {
|
|
uri.searchParams.set(key, params[key]);
|
|
}
|
|
|
|
return uri.href;
|
|
}
|
|
|
|
/**
|
|
* Helper function to create the attributes needed for the new clone.
|
|
* This mainly adds the necessary media attributes.
|
|
*
|
|
* @param {string} id The pathway clone object ID.
|
|
* @param {Object} oldAttributes The old attributes to compare to.
|
|
* @return {Object} The new attributes to add to the playlist.
|
|
*/
|
|
createCloneAttributes_(id, oldAttributes) {
|
|
const attributes = { ['PATHWAY-ID']: id };
|
|
|
|
['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((mediaType) => {
|
|
if (oldAttributes[mediaType]) {
|
|
attributes[mediaType] = id;
|
|
}
|
|
});
|
|
|
|
return attributes;
|
|
}
|
|
|
|
/**
|
|
* Returns the key ID set from a playlist
|
|
*
|
|
* @param {playlist} playlist to fetch the key ID set from.
|
|
* @return a Set of 32 digit hex strings that represent the unique keyIds for that playlist.
|
|
*/
|
|
getKeyIdSet(playlist) {
|
|
if (playlist.contentProtection) {
|
|
const keyIds = new Set();
|
|
|
|
for (const keysystem in playlist.contentProtection) {
|
|
const keyId = playlist.contentProtection[keysystem].attributes.keyId;
|
|
|
|
if (keyId) {
|
|
keyIds.add(keyId.toLowerCase());
|
|
}
|
|
}
|
|
return keyIds;
|
|
}
|
|
}
|
|
}
|