UNPKG

@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
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; } } }