@videojs/http-streaming
Version:
Play back HLS and DASH with Video.js, even where it's not natively supported
1,016 lines (851 loc) • 31 kB
JavaScript
import videojs from 'video.js';
import {
parse as parseMpd,
addSidxSegmentsToPlaylist,
generateSidxKey,
parseUTCTiming
} from 'mpd-parser';
import {
refreshDelay,
updateMain as updatePlaylist,
isPlaylistUnchanged
} from './playlist-loader';
import { resolveUrl, resolveManifestRedirect } from './resolve-url';
import parseSidx from 'mux.js/lib/tools/parse-sidx';
import { segmentXhrHeaders } from './xhr';
import window from 'global/window';
import {
forEachMediaGroup,
addPropertiesToMain
} from './manifest';
import containerRequest from './util/container-request.js';
import {toUint8} from '@videojs/vhs-utils/es/byte-helpers';
import logger from './util/logger';
import {merge} from './util/vjs-compat';
import { getStreamingNetworkErrorMetadata } from './error-codes.js';
const { EventTarget } = videojs;
const dashPlaylistUnchanged = function(a, b) {
if (!isPlaylistUnchanged(a, b)) {
return false;
}
// for dash the above check will often return true in scenarios where
// the playlist actually has changed because mediaSequence isn't a
// dash thing, and we often set it to 1. So if the playlists have the same amount
// of segments we return true.
// So for dash we need to make sure that the underlying segments are different.
// if sidx changed then the playlists are different.
if (a.sidx && b.sidx && (a.sidx.offset !== b.sidx.offset || a.sidx.length !== b.sidx.length)) {
return false;
} else if ((!a.sidx && b.sidx) || (a.sidx && !b.sidx)) {
return false;
}
// one or the other does not have segments
// there was a change.
if (a.segments && !b.segments || !a.segments && b.segments) {
return false;
}
// neither has segments nothing changed
if (!a.segments && !b.segments) {
return true;
}
// check segments themselves
for (let i = 0; i < a.segments.length; i++) {
const aSegment = a.segments[i];
const bSegment = b.segments[i];
// if uris are different between segments there was a change
if (aSegment.uri !== bSegment.uri) {
return false;
}
// neither segment has a byterange, there will be no byterange change.
if (!aSegment.byterange && !bSegment.byterange) {
continue;
}
const aByterange = aSegment.byterange;
const bByterange = bSegment.byterange;
// if byterange only exists on one of the segments, there was a change.
if ((aByterange && !bByterange) || (!aByterange && bByterange)) {
return false;
}
// if both segments have byterange with different offsets, there was a change.
if (aByterange.offset !== bByterange.offset || aByterange.length !== bByterange.length) {
return false;
}
}
// if everything was the same with segments, this is the same playlist.
return true;
};
/**
* Use the representation IDs from the mpd object to create groupIDs, the NAME is set to mandatory representation
* ID in the parser. This allows for continuous playout across periods with the same representation IDs
* (continuous periods as defined in DASH-IF 3.2.12). This is assumed in the mpd-parser as well. If we want to support
* periods without continuous playback this function may need modification as well as the parser.
*/
const dashGroupId = (type, group, label, playlist) => {
// If the manifest somehow does not have an ID (non-dash compliant), use the label.
const playlistId = playlist.attributes.NAME || label;
return `placeholder-uri-${type}-${group}-${playlistId}`;
};
/**
* Parses the main XML string and updates playlist URI references.
*
* @param {Object} config
* Object of arguments
* @param {string} config.mainXml
* The mpd XML
* @param {string} config.srcUrl
* The mpd URL
* @param {Date} config.clientOffset
* A time difference between server and client
* @param {Object} config.sidxMapping
* SIDX mappings for moof/mdat URIs and byte ranges
* @return {Object}
* The parsed mpd manifest object
*/
export const parseMainXml = ({
mainXml,
srcUrl,
clientOffset,
sidxMapping,
previousManifest
}) => {
const manifest = parseMpd(mainXml, {
manifestUri: srcUrl,
clientOffset,
sidxMapping,
previousManifest
});
addPropertiesToMain(manifest, srcUrl, dashGroupId);
return manifest;
};
/**
* Removes any mediaGroup labels that no longer exist in the newMain
*
* @param {Object} update
* The previous mpd object being updated
* @param {Object} newMain
* The new mpd object
*/
const removeOldMediaGroupLabels = (update, newMain) => {
forEachMediaGroup(update, (properties, type, group, label) => {
if (!newMain.mediaGroups[type][group] || !(label in newMain.mediaGroups[type][group])) {
delete update.mediaGroups[type][group][label];
}
});
};
/**
* Returns a new main manifest that is the result of merging an updated main manifest
* into the original version.
*
* @param {Object} oldMain
* The old parsed mpd object
* @param {Object} newMain
* The updated parsed mpd object
* @return {Object}
* A new object representing the original main manifest with the updated media
* playlists merged in
*/
export const updateMain = (oldMain, newMain, sidxMapping) => {
let noChanges = true;
let update = merge(oldMain, {
// These are top level properties that can be updated
duration: newMain.duration,
minimumUpdatePeriod: newMain.minimumUpdatePeriod,
timelineStarts: newMain.timelineStarts
});
// First update the playlists in playlist list
for (let i = 0; i < newMain.playlists.length; i++) {
const playlist = newMain.playlists[i];
if (playlist.sidx) {
const sidxKey = generateSidxKey(playlist.sidx);
// add sidx segments to the playlist if we have all the sidx info already
if (sidxMapping && sidxMapping[sidxKey] && sidxMapping[sidxKey].sidx) {
addSidxSegmentsToPlaylist(playlist, sidxMapping[sidxKey].sidx, playlist.sidx.resolvedUri);
}
}
const playlistUpdate = updatePlaylist(update, playlist, dashPlaylistUnchanged);
if (playlistUpdate) {
update = playlistUpdate;
noChanges = false;
}
}
// Then update media group playlists
forEachMediaGroup(newMain, (properties, type, group, label) => {
if (properties.playlists && properties.playlists.length) {
const id = properties.playlists[0].id;
const playlistUpdate = updatePlaylist(update, properties.playlists[0], dashPlaylistUnchanged);
if (playlistUpdate) {
update = playlistUpdate;
// add new mediaGroup label if it doesn't exist and assign the new mediaGroup.
if (!(label in update.mediaGroups[type][group])) {
update.mediaGroups[type][group][label] = properties;
}
// update the playlist reference within media groups
update.mediaGroups[type][group][label].playlists[0] = update.playlists[id];
noChanges = false;
}
}
});
// remove mediaGroup labels and references that no longer exist in the newMain
removeOldMediaGroupLabels(update, newMain);
if (newMain.minimumUpdatePeriod !== oldMain.minimumUpdatePeriod) {
noChanges = false;
}
if (noChanges) {
return null;
}
return update;
};
// SIDX should be equivalent if the URI and byteranges of the SIDX match.
// If the SIDXs have maps, the two maps should match,
// both `a` and `b` missing SIDXs is considered matching.
// If `a` or `b` but not both have a map, they aren't matching.
const equivalentSidx = (a, b) => {
const neitherMap = Boolean(!a.map && !b.map);
const equivalentMap = neitherMap || Boolean(a.map && b.map &&
a.map.byterange.offset === b.map.byterange.offset &&
a.map.byterange.length === b.map.byterange.length);
return equivalentMap &&
a.uri === b.uri &&
a.byterange.offset === b.byterange.offset &&
a.byterange.length === b.byterange.length;
};
// exported for testing
export const compareSidxEntry = (playlists, oldSidxMapping) => {
const newSidxMapping = {};
for (const id in playlists) {
const playlist = playlists[id];
const currentSidxInfo = playlist.sidx;
if (currentSidxInfo) {
const key = generateSidxKey(currentSidxInfo);
if (!oldSidxMapping[key]) {
break;
}
const savedSidxInfo = oldSidxMapping[key].sidxInfo;
if (equivalentSidx(savedSidxInfo, currentSidxInfo)) {
newSidxMapping[key] = oldSidxMapping[key];
}
}
}
return newSidxMapping;
};
/**
* A function that filters out changed items as they need to be requested separately.
*
* The method is exported for testing
*
* @param {Object} main the parsed mpd XML returned via mpd-parser
* @param {Object} oldSidxMapping the SIDX to compare against
*/
export const filterChangedSidxMappings = (main, oldSidxMapping) => {
const videoSidx = compareSidxEntry(main.playlists, oldSidxMapping);
let mediaGroupSidx = videoSidx;
forEachMediaGroup(main, (properties, mediaType, groupKey, labelKey) => {
if (properties.playlists && properties.playlists.length) {
const playlists = properties.playlists;
mediaGroupSidx = merge(
mediaGroupSidx,
compareSidxEntry(playlists, oldSidxMapping)
);
}
});
return mediaGroupSidx;
};
export default class DashPlaylistLoader extends EventTarget {
// DashPlaylistLoader must accept either a src url or a playlist because subsequent
// playlist loader setups from media groups will expect to be able to pass a playlist
// (since there aren't external URLs to media playlists with DASH)
constructor(srcUrlOrPlaylist, vhs, options = { }, mainPlaylistLoader) {
super();
this.mainPlaylistLoader_ = mainPlaylistLoader || this;
if (!mainPlaylistLoader) {
this.isMain_ = true;
}
const { withCredentials = false } = options;
this.vhs_ = vhs;
this.withCredentials = withCredentials;
this.addMetadataToTextTrack = options.addMetadataToTextTrack;
if (!srcUrlOrPlaylist) {
throw new Error('A non-empty playlist URL or object is required');
}
// event naming?
this.on('minimumUpdatePeriod', () => {
this.refreshXml_();
});
// live playlist staleness timeout
this.on('mediaupdatetimeout', () => {
this.refreshMedia_(this.media().id);
});
this.state = 'HAVE_NOTHING';
this.loadedPlaylists_ = {};
this.logger_ = logger('DashPlaylistLoader');
// initialize the loader state
// The mainPlaylistLoader will be created with a string
if (this.isMain_) {
this.mainPlaylistLoader_.srcUrl = srcUrlOrPlaylist;
// TODO: reset sidxMapping between period changes
// once multi-period is refactored
this.mainPlaylistLoader_.sidxMapping_ = {};
} else {
this.childPlaylist_ = srcUrlOrPlaylist;
}
}
requestErrored_(err, request, startingState) {
// disposed
if (!this.request) {
return true;
}
// pending request is cleared
this.request = null;
if (err) {
// use the provided error object or create one
// based on the request/response
this.error = typeof err === 'object' && !(err instanceof Error) ? err : {
status: request.status,
message: 'DASH request error at URL: ' + request.uri,
response: request.response,
// MEDIA_ERR_NETWORK
code: 2,
metadata: err.metadata
};
if (startingState) {
this.state = startingState;
}
this.trigger('error');
return true;
}
}
/**
* Verify that the container of the sidx segment can be parsed
* and if it can, get and parse that segment.
*/
addSidxSegments_(playlist, startingState, cb) {
const sidxKey = playlist.sidx && generateSidxKey(playlist.sidx);
// playlist lacks sidx or sidx segments were added to this playlist already.
if (!playlist.sidx || !sidxKey || this.mainPlaylistLoader_.sidxMapping_[sidxKey]) {
// keep this function async
this.mediaRequest_ = window.setTimeout(() => cb(false), 0);
return;
}
// resolve the segment URL relative to the playlist
const uri = resolveManifestRedirect(playlist.sidx.resolvedUri);
const fin = (err, request) => {
if (this.requestErrored_(err, request, startingState)) {
return;
}
const sidxMapping = this.mainPlaylistLoader_.sidxMapping_;
const { requestType } = request;
let sidx;
try {
sidx = parseSidx(toUint8(request.response).subarray(8));
} catch (e) {
e.metadata = getStreamingNetworkErrorMetadata({requestType, request, parseFailure: true });
// sidx parsing failed.
this.requestErrored_(e, request, startingState);
return;
}
sidxMapping[sidxKey] = {
sidxInfo: playlist.sidx,
sidx
};
addSidxSegmentsToPlaylist(playlist, sidx, playlist.sidx.resolvedUri);
return cb(true);
};
const REQUEST_TYPE = 'dash-sidx';
this.request = containerRequest(uri, this.vhs_.xhr, (err, request, container, bytes) => {
if (err) {
return fin(err, request);
}
if (!container || container !== 'mp4') {
const sidxContainer = container || 'unknown';
return fin({
status: request.status,
message: `Unsupported ${sidxContainer} container type for sidx segment at URL: ${uri}`,
// response is just bytes in this case
// but we really don't want to return that.
response: '',
playlist,
internal: true,
playlistExclusionDuration: Infinity,
// MEDIA_ERR_NETWORK
code: 2
}, request);
}
// if we already downloaded the sidx bytes in the container request, use them
const {offset, length} = playlist.sidx.byterange;
if (bytes.length >= (length + offset)) {
return fin(err, {
response: bytes.subarray(offset, offset + length),
status: request.status,
uri: request.uri
});
}
// otherwise request sidx bytes
this.request = this.vhs_.xhr({
uri,
responseType: 'arraybuffer',
requestType: 'dash-sidx',
headers: segmentXhrHeaders({byterange: playlist.sidx.byterange})
}, fin);
}, REQUEST_TYPE);
}
dispose() {
this.trigger('dispose');
this.stopRequest();
this.loadedPlaylists_ = {};
window.clearTimeout(this.minimumUpdatePeriodTimeout_);
window.clearTimeout(this.mediaRequest_);
window.clearTimeout(this.mediaUpdateTimeout);
this.mediaUpdateTimeout = null;
this.mediaRequest_ = null;
this.minimumUpdatePeriodTimeout_ = null;
if (this.mainPlaylistLoader_.createMupOnMedia_) {
this.off('loadedmetadata', this.mainPlaylistLoader_.createMupOnMedia_);
this.mainPlaylistLoader_.createMupOnMedia_ = null;
}
this.off();
}
hasPendingRequest() {
return this.request || this.mediaRequest_;
}
stopRequest() {
if (this.request) {
const oldRequest = this.request;
this.request = null;
oldRequest.onreadystatechange = null;
oldRequest.abort();
}
}
media(playlist) {
// getter
if (!playlist) {
return this.media_;
}
// setter
if (this.state === 'HAVE_NOTHING') {
throw new Error('Cannot switch media playlist from ' + this.state);
}
const startingState = this.state;
// find the playlist object if the target playlist has been specified by URI
if (typeof playlist === 'string') {
if (!this.mainPlaylistLoader_.main.playlists[playlist]) {
throw new Error('Unknown playlist URI: ' + playlist);
}
playlist = this.mainPlaylistLoader_.main.playlists[playlist];
}
const mediaChange = !this.media_ || playlist.id !== this.media_.id;
// switch to previously loaded playlists immediately
if (mediaChange &&
this.loadedPlaylists_[playlist.id] &&
this.loadedPlaylists_[playlist.id].endList) {
this.state = 'HAVE_METADATA';
this.media_ = playlist;
// trigger media change if the active media has been updated
if (mediaChange) {
this.trigger('mediachanging');
this.trigger('mediachange');
}
return;
}
// switching to the active playlist is a no-op
if (!mediaChange) {
return;
}
// switching from an already loaded playlist
if (this.media_) {
this.trigger('mediachanging');
}
this.addSidxSegments_(playlist, startingState, (sidxChanged) => {
// everything is ready just continue to haveMetadata
this.haveMetadata({startingState, playlist});
});
}
haveMetadata({startingState, playlist}) {
this.state = 'HAVE_METADATA';
this.loadedPlaylists_[playlist.id] = playlist;
this.mediaRequest_ = null;
// This will trigger loadedplaylist
this.refreshMedia_(playlist.id);
// fire loadedmetadata the first time a media playlist is loaded
// to resolve setup of media groups
if (startingState === 'HAVE_MAIN_MANIFEST') {
this.trigger('loadedmetadata');
} else {
// trigger media change if the active media has been updated
this.trigger('mediachange');
}
}
pause() {
if (this.mainPlaylistLoader_.createMupOnMedia_) {
this.off('loadedmetadata', this.mainPlaylistLoader_.createMupOnMedia_);
this.mainPlaylistLoader_.createMupOnMedia_ = null;
}
this.stopRequest();
window.clearTimeout(this.mediaUpdateTimeout);
this.mediaUpdateTimeout = null;
if (this.isMain_) {
window.clearTimeout(this.mainPlaylistLoader_.minimumUpdatePeriodTimeout_);
this.mainPlaylistLoader_.minimumUpdatePeriodTimeout_ = null;
}
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;
}
}
load(isFinalRendition) {
window.clearTimeout(this.mediaUpdateTimeout);
this.mediaUpdateTimeout = null;
const media = this.media();
if (isFinalRendition) {
const delay = media ? (media.targetDuration / 2) * 1000 : 5 * 1000;
this.mediaUpdateTimeout = window.setTimeout(() => this.load(), delay);
return;
}
// because the playlists are internal to the manifest, load should either load the
// main manifest, or do nothing but trigger an event
if (!this.started) {
this.start();
return;
}
if (media && !media.endList) {
// Check to see if this is the main loader and the MUP was cleared (this happens
// when the loader was paused). `media` should be set at this point since one is always
// set during `start()`.
if (this.isMain_ && !this.minimumUpdatePeriodTimeout_) {
// Trigger minimumUpdatePeriod to refresh the main manifest
this.trigger('minimumUpdatePeriod');
// Since there was no prior minimumUpdatePeriodTimeout it should be recreated
this.updateMinimumUpdatePeriodTimeout_();
}
this.trigger('mediaupdatetimeout');
} else {
this.trigger('loadedplaylist');
}
}
start() {
this.started = true;
// We don't need to request the main manifest again
// Call this asynchronously to match the xhr request behavior below
if (!this.isMain_) {
this.mediaRequest_ = window.setTimeout(() => this.haveMain_(), 0);
return;
}
this.requestMain_((req, mainChanged) => {
this.haveMain_();
if (!this.hasPendingRequest() && !this.media_) {
this.media(this.mainPlaylistLoader_.main.playlists[0]);
}
});
}
requestMain_(cb) {
const metadata = {
manifestInfo: {
uri: this.mainPlaylistLoader_.srcUrl
}
};
this.trigger({type: 'manifestrequeststart', metadata});
this.request = this.vhs_.xhr({
uri: this.mainPlaylistLoader_.srcUrl,
withCredentials: this.withCredentials,
requestType: 'dash-manifest'
}, (error, req) => {
if (error) {
const { requestType } = req;
error.metadata = getStreamingNetworkErrorMetadata({ requestType, request: req, error });
}
if (this.requestErrored_(error, req)) {
if (this.state === 'HAVE_NOTHING') {
this.started = false;
}
return;
}
this.trigger({type: 'manifestrequestcomplete', metadata});
const mainChanged = req.responseText !== this.mainPlaylistLoader_.mainXml_;
this.mainPlaylistLoader_.mainXml_ = req.responseText;
if (req.responseHeaders && req.responseHeaders.date) {
this.mainLoaded_ = Date.parse(req.responseHeaders.date);
} else {
this.mainLoaded_ = Date.now();
}
this.mainPlaylistLoader_.srcUrl = resolveManifestRedirect(this.mainPlaylistLoader_.srcUrl, req);
if (mainChanged) {
this.handleMain_();
this.syncClientServerClock_(() => {
return cb(req, mainChanged);
});
return;
}
return cb(req, mainChanged);
});
}
/**
* Parses the main xml for UTCTiming node to sync the client clock to the server
* clock. If the UTCTiming node requires a HEAD or GET request, that request is made.
*
* @param {Function} done
* Function to call when clock sync has completed
*/
syncClientServerClock_(done) {
const utcTiming = parseUTCTiming(this.mainPlaylistLoader_.mainXml_);
// No UTCTiming element found in the mpd. Use Date header from mpd request as the
// server clock
if (utcTiming === null) {
this.mainPlaylistLoader_.clientOffset_ = this.mainLoaded_ - Date.now();
return done();
}
if (utcTiming.method === 'DIRECT') {
this.mainPlaylistLoader_.clientOffset_ = utcTiming.value - Date.now();
return done();
}
this.request = this.vhs_.xhr({
uri: resolveUrl(this.mainPlaylistLoader_.srcUrl, utcTiming.value),
method: utcTiming.method,
withCredentials: this.withCredentials,
requestType: 'dash-clock-sync'
}, (error, req) => {
// disposed
if (!this.request) {
return;
}
if (error) {
const { requestType } = req;
this.error.metadata = getStreamingNetworkErrorMetadata({ requestType, request: req, error });
// sync request failed, fall back to using date header from mpd
// TODO: log warning
this.mainPlaylistLoader_.clientOffset_ = this.mainLoaded_ - Date.now();
return done();
}
let serverTime;
if (utcTiming.method === 'HEAD') {
if (!req.responseHeaders || !req.responseHeaders.date) {
// expected date header not preset, fall back to using date header from mpd
// TODO: log warning
serverTime = this.mainLoaded_;
} else {
serverTime = Date.parse(req.responseHeaders.date);
}
} else {
serverTime = Date.parse(req.responseText);
}
this.mainPlaylistLoader_.clientOffset_ = serverTime - Date.now();
done();
});
}
haveMain_() {
this.state = 'HAVE_MAIN_MANIFEST';
if (this.isMain_) {
// We have the main playlist at this point, so
// trigger this to allow PlaylistController
// to make an initial playlist selection
this.trigger('loadedplaylist');
} else if (!this.media_) {
// no media playlist was specifically selected so select
// the one the child playlist loader was created with
this.media(this.childPlaylist_);
}
}
handleMain_() {
// clear media request
this.mediaRequest_ = null;
const oldMain = this.mainPlaylistLoader_.main;
const metadata = {
manifestInfo: {
uri: this.mainPlaylistLoader_.srcUrl
}
};
this.trigger({type: 'manifestparsestart', metadata});
let newMain;
try {
newMain = parseMainXml({
mainXml: this.mainPlaylistLoader_.mainXml_,
srcUrl: this.mainPlaylistLoader_.srcUrl,
clientOffset: this.mainPlaylistLoader_.clientOffset_,
sidxMapping: this.mainPlaylistLoader_.sidxMapping_,
previousManifest: oldMain
});
} catch (error) {
this.error = error;
this.error.metadata = {
errorType: videojs.Error.StreamingDashManifestParserError,
error
};
this.trigger('error');
}
// if we have an old main to compare the new main against
if (oldMain) {
newMain = updateMain(oldMain, newMain, this.mainPlaylistLoader_.sidxMapping_);
}
// only update main if we have a new main
this.mainPlaylistLoader_.main = newMain ? newMain : oldMain;
const location = this.mainPlaylistLoader_.main.locations && this.mainPlaylistLoader_.main.locations[0];
if (location && location !== this.mainPlaylistLoader_.srcUrl) {
this.mainPlaylistLoader_.srcUrl = location;
}
if (!oldMain || (newMain && newMain.minimumUpdatePeriod !== oldMain.minimumUpdatePeriod)) {
this.updateMinimumUpdatePeriodTimeout_();
}
this.addEventStreamToMetadataTrack_(newMain);
if (newMain) {
const { duration, endList } = newMain;
const renditions = [];
newMain.playlists.forEach((playlist) => {
renditions.push({
id: playlist.id,
bandwidth: playlist.attributes.BANDWIDTH,
resolution: playlist.attributes.RESOLUTION,
codecs: playlist.attributes.CODECS
});
});
const parsedManifest = {
duration,
isLive: !endList,
renditions
};
metadata.parsedManifest = parsedManifest;
this.trigger({type: 'manifestparsecomplete', metadata});
}
return Boolean(newMain);
}
updateMinimumUpdatePeriodTimeout_() {
const mpl = this.mainPlaylistLoader_;
// cancel any pending creation of mup on media
// a new one will be added if needed.
if (mpl.createMupOnMedia_) {
mpl.off('loadedmetadata', mpl.createMupOnMedia_);
mpl.createMupOnMedia_ = null;
}
// clear any pending timeouts
if (mpl.minimumUpdatePeriodTimeout_) {
window.clearTimeout(mpl.minimumUpdatePeriodTimeout_);
mpl.minimumUpdatePeriodTimeout_ = null;
}
let mup = mpl.main && mpl.main.minimumUpdatePeriod;
// If the minimumUpdatePeriod has a value of 0, that indicates that the current
// MPD has no future validity, so a new one will need to be acquired when new
// media segments are to be made available. Thus, we use the target duration
// in this case
if (mup === 0) {
if (mpl.media()) {
mup = mpl.media().targetDuration * 1000;
} else {
mpl.createMupOnMedia_ = mpl.updateMinimumUpdatePeriodTimeout_;
mpl.one('loadedmetadata', mpl.createMupOnMedia_);
}
}
// if minimumUpdatePeriod is invalid or <= zero, which
// can happen when a live video becomes VOD. skip timeout
// creation.
if (typeof mup !== 'number' || mup <= 0) {
if (mup < 0) {
this.logger_(`found invalid minimumUpdatePeriod of ${mup}, not setting a timeout`);
}
return;
}
this.createMUPTimeout_(mup);
}
createMUPTimeout_(mup) {
const mpl = this.mainPlaylistLoader_;
mpl.minimumUpdatePeriodTimeout_ = window.setTimeout(() => {
mpl.minimumUpdatePeriodTimeout_ = null;
mpl.trigger('minimumUpdatePeriod');
mpl.createMUPTimeout_(mup);
}, mup);
}
/**
* Sends request to refresh the main xml and updates the parsed main manifest
*/
refreshXml_() {
this.requestMain_((req, mainChanged) => {
if (!mainChanged) {
return;
}
if (this.media_) {
this.media_ = this.mainPlaylistLoader_.main.playlists[this.media_.id];
}
// This will filter out updated sidx info from the mapping
this.mainPlaylistLoader_.sidxMapping_ = filterChangedSidxMappings(
this.mainPlaylistLoader_.main,
this.mainPlaylistLoader_.sidxMapping_
);
this.addSidxSegments_(this.media(), this.state, (sidxChanged) => {
// TODO: do we need to reload the current playlist?
this.refreshMedia_(this.media().id);
});
});
}
/**
* Refreshes the media playlist by re-parsing the main xml and updating playlist
* references. If this is an alternate loader, the updated parsed manifest is retrieved
* from the main loader.
*/
refreshMedia_(mediaID) {
if (!mediaID) {
throw new Error('refreshMedia_ must take a media id');
}
// for main we have to reparse the main xml
// to re-create segments based on current timing values
// which may change media. We only skip updating the main manifest
// if this is the first time this.media_ is being set.
// as main was just parsed in that case.
if (this.media_ && this.isMain_) {
this.handleMain_();
}
const playlists = this.mainPlaylistLoader_.main.playlists;
const mediaChanged = !this.media_ || this.media_ !== playlists[mediaID];
if (mediaChanged) {
this.media_ = playlists[mediaID];
} else {
this.trigger('playlistunchanged');
}
if (!this.mediaUpdateTimeout) {
const createMediaUpdateTimeout = () => {
if (this.media().endList) {
return;
}
this.mediaUpdateTimeout = window.setTimeout(() => {
this.trigger('mediaupdatetimeout');
createMediaUpdateTimeout();
}, refreshDelay(this.media(), Boolean(mediaChanged)));
};
createMediaUpdateTimeout();
}
this.trigger('loadedplaylist');
}
/**
* Takes eventstream data from a parsed DASH manifest and adds it to the metadata text track.
*
* @param {manifest} newMain the newly parsed manifest
*/
addEventStreamToMetadataTrack_(newMain) {
// Only add new event stream metadata if we have a new manifest.
if (newMain && this.mainPlaylistLoader_.main.eventStream) {
// convert EventStream to ID3-like data.
const metadataArray = this.mainPlaylistLoader_.main.eventStream.map((eventStreamNode) => {
return {
cueTime: eventStreamNode.start,
frames: [{ data: eventStreamNode.messageData }]
};
});
this.addMetadataToTextTrack('EventStream', metadataArray, this.mainPlaylistLoader_.main.duration);
}
}
/**
* 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 defaultKID = playlist.contentProtection[keysystem].attributes['cenc:default_KID'];
if (defaultKID) {
// DASH keyIds are separated by dashes.
keyIds.add(defaultKID.replace(/-/g, '').toLowerCase());
}
}
return keyIds;
}
}
}