@videojs/http-streaming
Version:
Play back HLS and DASH with Video.js, even where it's not natively supported
867 lines (728 loc) • 25.3 kB
JavaScript
import videojs from 'video.js';
import {
parse as parseMpd,
parseUTCTiming
} from 'mpd-parser';
import {
refreshDelay,
updateMaster as updatePlaylist
} 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,
addPropertiesToMaster
} from './manifest';
import containerRequest from './util/container-request.js';
import {toUint8} from '@videojs/vhs-utils/dist/byte-helpers';
const { EventTarget, mergeOptions } = videojs;
/**
* Parses the master XML string and updates playlist URI references.
*
* @param {Object} config
* Object of arguments
* @param {string} config.masterXml
* 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 parseMasterXml = ({ masterXml, srcUrl, clientOffset, sidxMapping }) => {
const master = parseMpd(masterXml, {
manifestUri: srcUrl,
clientOffset,
sidxMapping
});
addPropertiesToMaster(master, srcUrl);
return master;
};
/**
* Returns a new master manifest that is the result of merging an updated master manifest
* into the original version.
*
* @param {Object} oldMaster
* The old parsed mpd object
* @param {Object} newMaster
* The updated parsed mpd object
* @return {Object}
* A new object representing the original master manifest with the updated media
* playlists merged in
*/
export const updateMaster = (oldMaster, newMaster) => {
let noChanges = true;
let update = mergeOptions(oldMaster, {
// These are top level properties that can be updated
duration: newMaster.duration,
minimumUpdatePeriod: newMaster.minimumUpdatePeriod
});
// First update the playlists in playlist list
for (let i = 0; i < newMaster.playlists.length; i++) {
const playlistUpdate = updatePlaylist(update, newMaster.playlists[i]);
if (playlistUpdate) {
update = playlistUpdate;
noChanges = false;
}
}
// Then update media group playlists
forEachMediaGroup(newMaster, (properties, type, group, label) => {
if (properties.playlists && properties.playlists.length) {
const id = properties.playlists[0].id;
const playlistUpdate = updatePlaylist(update, properties.playlists[0]);
if (playlistUpdate) {
update = playlistUpdate;
// update the playlist reference within media groups
update.mediaGroups[type][group][label].playlists[0] = update.playlists[id];
noChanges = false;
}
}
});
if (newMaster.minimumUpdatePeriod !== oldMaster.minimumUpdatePeriod) {
noChanges = false;
}
if (noChanges) {
return null;
}
return update;
};
export const generateSidxKey = (sidxInfo) => {
// should be non-inclusive
const sidxByteRangeEnd =
sidxInfo.byterange.offset +
sidxInfo.byterange.length -
1;
return sidxInfo.uri + '-' +
sidxInfo.byterange.offset + '-' +
sidxByteRangeEnd;
};
// 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} masterXml the mpd XML
* @param {string} srcUrl the mpd url
* @param {Date} clientOffset a time difference between server and client (passed through and not used)
* @param {Object} oldSidxMapping the SIDX to compare against
*/
export const filterChangedSidxMappings = (masterXml, srcUrl, clientOffset, oldSidxMapping) => {
// Don't pass current sidx mapping
const master = parseMpd(masterXml, {
manifestUri: srcUrl,
clientOffset
});
const videoSidx = compareSidxEntry(master.playlists, oldSidxMapping);
let mediaGroupSidx = videoSidx;
forEachMediaGroup(master, (properties, mediaType, groupKey, labelKey) => {
if (properties.playlists && properties.playlists.length) {
const playlists = properties.playlists;
mediaGroupSidx = mergeOptions(
mediaGroupSidx,
compareSidxEntry(playlists, oldSidxMapping)
);
}
});
return mediaGroupSidx;
};
// exported for testing
export const requestSidx_ = (loader, sidxRange, playlist, xhr, options, finishProcessingFn) => {
const sidxInfo = {
// resolve the segment URL relative to the playlist
uri: resolveManifestRedirect(options.handleManifestRedirects, sidxRange.resolvedUri),
// resolvedUri: sidxRange.resolvedUri,
byterange: sidxRange.byterange,
// the segment's playlist
playlist
};
const sidxRequestOptions = videojs.mergeOptions(sidxInfo, {
responseType: 'arraybuffer',
headers: segmentXhrHeaders(sidxInfo)
});
return containerRequest(sidxInfo.uri, xhr, (err, request, container, bytes) => {
if (err) {
return finishProcessingFn(err, request);
}
if (!container || container !== 'mp4') {
return finishProcessingFn({
status: request.status,
message: `Unsupported ${container || 'unknown'} container type for sidx segment at URL: ${sidxInfo.uri}`,
// response is just bytes in this case
// but we really don't want to return that.
response: '',
playlist,
internal: true,
blacklistDuration: Infinity,
// MEDIA_ERR_NETWORK
code: 2
}, request);
}
// if we already downloaded the sidx bytes in the container request, use them
const {offset, length} = sidxInfo.byterange;
if (bytes.length >= (length + offset)) {
return finishProcessingFn(err, {
response: bytes.subarray(offset, offset + length),
status: request.status,
uri: request.uri
});
}
// otherwise request sidx bytes
loader.request = xhr(sidxRequestOptions, finishProcessingFn);
});
};
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 = { }, masterPlaylistLoader) {
super();
const { withCredentials = false, handleManifestRedirects = false } = options;
this.vhs_ = vhs;
this.withCredentials = withCredentials;
this.handleManifestRedirects = handleManifestRedirects;
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_ = {};
// initialize the loader state
// The masterPlaylistLoader will be created with a string
if (typeof srcUrlOrPlaylist === 'string') {
this.srcUrl = srcUrlOrPlaylist;
// TODO: reset sidxMapping between period changes
// once multi-period is refactored
this.sidxMapping_ = {};
return;
}
this.setupChildLoader(masterPlaylistLoader, srcUrlOrPlaylist);
}
setupChildLoader(masterPlaylistLoader, playlist) {
this.masterPlaylistLoader_ = masterPlaylistLoader;
this.childPlaylist_ = playlist;
}
dispose() {
this.trigger('dispose');
this.stopRequest();
this.loadedPlaylists_ = {};
window.clearTimeout(this.minimumUpdatePeriodTimeout_);
window.clearTimeout(this.mediaRequest_);
window.clearTimeout(this.mediaUpdateTimeout);
this.off();
}
hasPendingRequest() {
return this.request || this.mediaRequest_;
}
stopRequest() {
if (this.request) {
const oldRequest = this.request;
this.request = null;
oldRequest.onreadystatechange = null;
oldRequest.abort();
}
}
sidxRequestFinished_(playlist, master, startingState, doneFn) {
return (err, request) => {
// disposed
if (!this.request) {
return;
}
// pending request is cleared
this.request = null;
if (err) {
// use the provided error or create one
// see requestSidx_ for the container request
// that can cause this.
this.error = typeof err === 'object' ? err : {
status: request.status,
message: 'DASH playlist request error at URL: ' + playlist.uri,
response: request.response,
// MEDIA_ERR_NETWORK
code: 2
};
if (startingState) {
this.state = startingState;
}
this.trigger('error');
return;
}
const bytes = toUint8(request.response);
const sidx = parseSidx(bytes.subarray(8));
return doneFn(master, sidx);
};
}
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.master.playlists[playlist]) {
throw new Error('Unknown playlist URI: ' + playlist);
}
playlist = this.master.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');
}
if (!playlist.sidx) {
// Continue asynchronously if there is no sidx
// wait one tick to allow haveMaster to run first on a child loader
this.mediaRequest_ = window.setTimeout(
this.haveMetadata.bind(this, { startingState, playlist }),
0
);
// exit early and don't do sidx work
return;
}
// we have sidx mappings
let oldMaster;
let sidxMapping;
// sidxMapping is used when parsing the masterXml, so store
// it on the masterPlaylistLoader
if (this.masterPlaylistLoader_) {
oldMaster = this.masterPlaylistLoader_.master;
sidxMapping = this.masterPlaylistLoader_.sidxMapping_;
} else {
oldMaster = this.master;
sidxMapping = this.sidxMapping_;
}
const sidxKey = generateSidxKey(playlist.sidx);
sidxMapping[sidxKey] = {
sidxInfo: playlist.sidx
};
this.request = requestSidx_(
this,
playlist.sidx,
playlist,
this.vhs_.xhr,
{ handleManifestRedirects: this.handleManifestRedirects },
this.sidxRequestFinished_(playlist, oldMaster, startingState, (newMaster, sidx) => {
if (!newMaster || !sidx) {
throw new Error('failed to request sidx');
}
// update loader's sidxMapping with parsed sidx box
sidxMapping[sidxKey].sidx = sidx;
// everything is ready just continue to haveMetadata
this.haveMetadata({
startingState,
playlist: newMaster.playlists[playlist.id]
});
})
);
}
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_MASTER') {
this.trigger('loadedmetadata');
} else {
// trigger media change if the active media has been updated
this.trigger('mediachange');
}
}
pause() {
this.stopRequest();
window.clearTimeout(this.mediaUpdateTimeout);
window.clearTimeout(this.minimumUpdatePeriodTimeout_);
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);
window.clearTimeout(this.minimumUpdatePeriodTimeout_);
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) {
this.trigger('mediaupdatetimeout');
} else {
this.trigger('loadedplaylist');
}
}
start() {
this.started = true;
// We don't need to request the master manifest again
// Call this asynchronously to match the xhr request behavior below
if (this.masterPlaylistLoader_) {
this.mediaRequest_ = window.setTimeout(
this.haveMaster_.bind(this),
0
);
return;
}
// request the specified URL
this.request = this.vhs_.xhr({
uri: this.srcUrl,
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: 'DASH playlist request error at URL: ' + this.srcUrl,
responseText: req.responseText,
// MEDIA_ERR_NETWORK
code: 2
};
if (this.state === 'HAVE_NOTHING') {
this.started = false;
}
return this.trigger('error');
}
this.masterXml_ = req.responseText;
if (req.responseHeaders && req.responseHeaders.date) {
this.masterLoaded_ = Date.parse(req.responseHeaders.date);
} else {
this.masterLoaded_ = Date.now();
}
this.srcUrl = resolveManifestRedirect(this.handleManifestRedirects, this.srcUrl, req);
this.syncClientServerClock_(this.onClientServerClockSync_.bind(this));
});
}
/**
* Parses the master 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.masterXml_);
// No UTCTiming element found in the mpd. Use Date header from mpd request as the
// server clock
if (utcTiming === null) {
this.clientOffset_ = this.masterLoaded_ - Date.now();
return done();
}
if (utcTiming.method === 'DIRECT') {
this.clientOffset_ = utcTiming.value - Date.now();
return done();
}
this.request = this.vhs_.xhr({
uri: resolveUrl(this.srcUrl, utcTiming.value),
method: utcTiming.method,
withCredentials: this.withCredentials
}, (error, req) => {
// disposed
if (!this.request) {
return;
}
if (error) {
// sync request failed, fall back to using date header from mpd
// TODO: log warning
this.clientOffset_ = this.masterLoaded_ - 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.masterLoaded_;
} else {
serverTime = Date.parse(req.responseHeaders.date);
}
} else {
serverTime = Date.parse(req.responseText);
}
this.clientOffset_ = serverTime - Date.now();
done();
});
}
haveMaster_() {
this.state = 'HAVE_MASTER';
// clear media request
this.mediaRequest_ = null;
if (!this.masterPlaylistLoader_) {
this.updateMainManifest_(parseMasterXml({
masterXml: this.masterXml_,
srcUrl: this.srcUrl,
clientOffset: this.clientOffset_,
sidxMapping: this.sidxMapping_
}));
// We have the master playlist at this point, so
// trigger this to allow MasterPlaylistController
// 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_);
}
}
updateMinimumUpdatePeriodTimeout_() {
// Clear existing timeout
window.clearTimeout(this.minimumUpdatePeriodTimeout_);
const createMUPTimeout = (mup) => {
this.minimumUpdatePeriodTimeout_ = window.setTimeout(() => {
this.trigger('minimumUpdatePeriod');
}, mup);
};
const minimumUpdatePeriod = this.master && this.master.minimumUpdatePeriod;
if (minimumUpdatePeriod > 0) {
createMUPTimeout(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
} else if (minimumUpdatePeriod === 0) {
// If we haven't yet selected a playlist, wait until then so we know the
// target duration
if (!this.media()) {
this.one('loadedplaylist', () => {
createMUPTimeout(this.media().targetDuration * 1000);
});
} else {
createMUPTimeout(this.media().targetDuration * 1000);
}
}
}
/**
* Handler for after client/server clock synchronization has happened. Sets up
* xml refresh timer if specificed by the manifest.
*/
onClientServerClockSync_() {
this.haveMaster_();
if (!this.hasPendingRequest() && !this.media_) {
this.media(this.master.playlists[0]);
}
this.updateMinimumUpdatePeriodTimeout_();
}
/**
* Given a new manifest, update our pointer to it and update the srcUrl based on the location elements of the manifest, if they exist.
*
* @param {Object} updatedManifest the manifest to update to
*/
updateMainManifest_(updatedManifest) {
this.master = updatedManifest;
// if locations isn't set or is an empty array, exit early
if (!this.master.locations || !this.master.locations.length) {
return;
}
const location = this.master.locations[0];
if (location !== this.srcUrl) {
this.srcUrl = location;
}
}
/**
* Sends request to refresh the master xml and updates the parsed master manifest
* TODO: Does the client offset need to be recalculated when the xml is refreshed?
*/
refreshXml_() {
// The srcUrl here *may* need to pass through handleManifestsRedirects when
// sidx is implemented
this.request = this.vhs_.xhr({
uri: this.srcUrl,
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: 'DASH playlist request error at URL: ' + this.srcUrl,
responseText: req.responseText,
// MEDIA_ERR_NETWORK
code: 2
};
if (this.state === 'HAVE_NOTHING') {
this.started = false;
}
return this.trigger('error');
}
this.masterXml_ = req.responseText;
// This will filter out updated sidx info from the mapping
this.sidxMapping_ = filterChangedSidxMappings(
this.masterXml_,
this.srcUrl,
this.clientOffset_,
this.sidxMapping_
);
const master = parseMasterXml({
masterXml: this.masterXml_,
srcUrl: this.srcUrl,
clientOffset: this.clientOffset_,
sidxMapping: this.sidxMapping_
});
const updatedMaster = updateMaster(this.master, master);
const currentSidxInfo = this.media().sidx;
if (updatedMaster) {
if (currentSidxInfo) {
const sidxKey = generateSidxKey(currentSidxInfo);
// the sidx was updated, so the previous mapping was removed
if (!this.sidxMapping_[sidxKey]) {
const playlist = this.media();
this.request = requestSidx_(
this,
playlist.sidx,
playlist,
this.vhs_.xhr,
{ handleManifestRedirects: this.handleManifestRedirects },
this.sidxRequestFinished_(playlist, master, this.state, (newMaster, sidx) => {
if (!newMaster || !sidx) {
throw new Error('failed to request sidx on minimumUpdatePeriod');
}
// update loader's sidxMapping with parsed sidx box
this.sidxMapping_[sidxKey].sidx = sidx;
this.updateMinimumUpdatePeriodTimeout_();
// TODO: do we need to reload the current playlist?
this.refreshMedia_(this.media().id);
return;
})
);
}
} else {
this.updateMainManifest_(updatedMaster);
if (this.media_) {
this.media_ = this.master.playlists[this.media_.id];
}
}
}
this.updateMinimumUpdatePeriodTimeout_();
});
}
/**
* Refreshes the media playlist by re-parsing the master xml and updating playlist
* references. If this is an alternate loader, the updated parsed manifest is retrieved
* from the master loader.
*/
refreshMedia_(mediaID) {
if (!mediaID) {
throw new Error('refreshMedia_ must take a media id');
}
let oldMaster;
let newMaster;
if (this.masterPlaylistLoader_) {
oldMaster = this.masterPlaylistLoader_.master;
newMaster = parseMasterXml({
masterXml: this.masterPlaylistLoader_.masterXml_,
srcUrl: this.masterPlaylistLoader_.srcUrl,
clientOffset: this.masterPlaylistLoader_.clientOffset_,
sidxMapping: this.masterPlaylistLoader_.sidxMapping_
});
} else {
oldMaster = this.master;
newMaster = parseMasterXml({
masterXml: this.masterXml_,
srcUrl: this.srcUrl,
clientOffset: this.clientOffset_,
sidxMapping: this.sidxMapping_
});
}
const updatedMaster = updateMaster(oldMaster, newMaster);
if (updatedMaster) {
if (this.masterPlaylistLoader_) {
this.masterPlaylistLoader_.master = updatedMaster;
} else {
this.master = updatedMaster;
}
this.media_ = updatedMaster.playlists[mediaID];
} else {
this.media_ = oldMaster.playlists[mediaID];
this.trigger('playlistunchanged');
}
if (!this.media().endList) {
this.mediaUpdateTimeout = window.setTimeout(() => {
this.trigger('mediaupdatetimeout');
}, refreshDelay(this.media(), !!updatedMaster));
}
this.trigger('loadedplaylist');
}
}