UNPKG

@videojs/http-streaming

Version:

Play back HLS and DASH with Video.js, even where it's not natively supported

741 lines (615 loc) 22.3 kB
import videojs from 'video.js'; import { parse as parseMpd, addSidxSegmentsToPlaylist, 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/es/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; }; export const generateSidxKey = (sidxInfo) => { // should be non-inclusive const sidxByteRangeEnd = sidxInfo.byterange.offset + sidxInfo.byterange.length - 1; return sidxInfo.uri + '-' + sidxInfo.byterange.offset + '-' + sidxByteRangeEnd; }; /** * 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, sidxMapping) => { 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 playlist = newMaster.playlists[i]; if (playlist.sidx) { const sidxKey = generateSidxKey(playlist.sidx); if (sidxMapping && sidxMapping[sidxKey]) { addSidxSegmentsToPlaylist(playlist, sidxMapping[sidxKey].sidx, playlist.sidx.resolvedUri); } } const playlistUpdate = updatePlaylist(update, playlist); 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; }; // 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} master the parsed mpd XML returned via mpd-parser * @param {Object} oldSidxMapping the SIDX to compare against */ export const filterChangedSidxMappings = (master, oldSidxMapping) => { 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; }; 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(); this.masterPlaylistLoader_ = masterPlaylistLoader || this; if (!masterPlaylistLoader) { this.isMaster_ = true; } 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 (this.isMaster_) { this.masterPlaylistLoader_.srcUrl = srcUrlOrPlaylist; // TODO: reset sidxMapping between period changes // once multi-period is refactored this.masterPlaylistLoader_.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 }; 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.masterPlaylistLoader_.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(this.handleManifestRedirects, playlist.sidx.resolvedUri); const sidxMapping = this.masterPlaylistLoader_.sidxMapping_; sidxMapping[sidxKey] = { sidxInfo: playlist.sidx }; const fin = (err, request) => { if (this.requestErrored_(err, request, startingState)) { return; } const sidx = parseSidx(toUint8(request.response).subarray(8)); sidxMapping[sidxKey].sidx = sidx; addSidxSegmentsToPlaylist(playlist, sidx, playlist.sidx.resolvedUri); return cb(true); }; this.request = containerRequest(uri, this.vhs_.xhr, (err, request, container, bytes) => { if (err) { return fin(err, request); } if (!container || container !== 'mp4') { return fin({ status: request.status, message: `Unsupported ${container || 'unknown'} 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, blacklistDuration: 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', headers: segmentXhrHeaders({byterange: playlist.sidx.byterange}) }, fin); }); } 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(); } } 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.masterPlaylistLoader_.master.playlists[playlist]) { throw new Error('Unknown playlist URI: ' + playlist); } playlist = this.masterPlaylistLoader_.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'); } 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_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.isMaster_) { this.mediaRequest_ = window.setTimeout(() => this.haveMaster_(), 0); return; } this.requestMaster_((req, masterChanged) => { this.haveMaster_(); if (!this.hasPendingRequest() && !this.media_) { this.media(this.masterPlaylistLoader_.master.playlists[0]); } }); } requestMaster_(cb) { this.request = this.vhs_.xhr({ uri: this.masterPlaylistLoader_.srcUrl, withCredentials: this.withCredentials }, (error, req) => { if (this.requestErrored_(error, req)) { if (this.state === 'HAVE_NOTHING') { this.started = false; } return; } const masterChanged = req.responseText !== this.masterPlaylistLoader_.masterXml_; this.masterPlaylistLoader_.masterXml_ = req.responseText; if (req.responseHeaders && req.responseHeaders.date) { this.masterLoaded_ = Date.parse(req.responseHeaders.date); } else { this.masterLoaded_ = Date.now(); } this.masterPlaylistLoader_.srcUrl = resolveManifestRedirect(this.handleManifestRedirects, this.masterPlaylistLoader_.srcUrl, req); if (masterChanged) { this.handleMaster_(); this.syncClientServerClock_(() => { return cb(req, masterChanged); }); return; } return cb(req, masterChanged); }); } /** * 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.masterPlaylistLoader_.masterXml_); // No UTCTiming element found in the mpd. Use Date header from mpd request as the // server clock if (utcTiming === null) { this.masterPlaylistLoader_.clientOffset_ = this.masterLoaded_ - Date.now(); return done(); } if (utcTiming.method === 'DIRECT') { this.masterPlaylistLoader_.clientOffset_ = utcTiming.value - Date.now(); return done(); } this.request = this.vhs_.xhr({ uri: resolveUrl(this.masterPlaylistLoader_.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.masterPlaylistLoader_.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.masterPlaylistLoader_.clientOffset_ = serverTime - Date.now(); done(); }); } haveMaster_() { this.state = 'HAVE_MASTER'; if (this.isMaster_) { // 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_); } } handleMaster_() { // clear media request this.mediaRequest_ = null; let newMaster = parseMasterXml({ masterXml: this.masterPlaylistLoader_.masterXml_, srcUrl: this.masterPlaylistLoader_.srcUrl, clientOffset: this.masterPlaylistLoader_.clientOffset_, sidxMapping: this.masterPlaylistLoader_.sidxMapping_ }); const oldMaster = this.masterPlaylistLoader_.master; // if we have an old master to compare the new master against if (oldMaster) { newMaster = updateMaster(oldMaster, newMaster, this.masterPlaylistLoader_.sidxMapping_); } // only update master if we have a new master this.masterPlaylistLoader_.master = newMaster ? newMaster : oldMaster; const location = this.masterPlaylistLoader_.master.locations && this.masterPlaylistLoader_.master.locations[0]; if (location && location !== this.masterPlaylistLoader_.srcUrl) { this.masterPlaylistLoader_.srcUrl = location; } // if the minimumUpdatePeriod was changed, update the minimumUpdatePeriodTimeout_ if (!oldMaster || (newMaster && oldMaster.minimumUpdatePeriod !== newMaster.minimumUpdatePeriod)) { this.updateMinimumUpdatePeriodTimeout_(); } return Boolean(newMaster); } updateMinimumUpdatePeriodTimeout_() { // Clear existing timeout window.clearTimeout(this.minimumUpdatePeriodTimeout_); const createMUPTimeout = (mup) => { this.minimumUpdatePeriodTimeout_ = window.setTimeout(() => { this.trigger('minimumUpdatePeriod'); createMUPTimeout(mup); }, mup); }; const minimumUpdatePeriod = this.masterPlaylistLoader_.master && this.masterPlaylistLoader_.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); } } } /** * Sends request to refresh the master xml and updates the parsed master manifest */ refreshXml_() { this.requestMaster_((req, masterChanged) => { if (!masterChanged) { return; } if (this.media_) { this.media_ = this.masterPlaylistLoader_.master.playlists[this.media_.id]; } // This will filter out updated sidx info from the mapping this.masterPlaylistLoader_.sidxMapping_ = filterChangedSidxMappings( this.masterPlaylistLoader_.master, this.masterPlaylistLoader_.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 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'); } // for master we have to reparse the master xml // to re-create segments based on current timing values // which may change media. We only skip updating master // if this is the first time this.media_ is being set. // as master was just parsed in that case. if (this.media_ && this.isMaster_) { this.handleMaster_(); } const playlists = this.masterPlaylistLoader_.master.playlists; const mediaChanged = !this.media_ || this.media_ !== playlists[mediaID]; if (mediaChanged) { this.media_ = playlists[mediaID]; } else { this.trigger('playlistunchanged'); } if (!this.media().endList) { this.mediaUpdateTimeout = window.setTimeout(() => { this.trigger('mediaupdatetimeout'); }, refreshDelay(this.media(), Boolean(mediaChanged))); } this.trigger('loadedplaylist'); } }