| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218 |
- /**
- * @file playlist-loader.js
- *
- * A state machine that manages the loading, caching, and updating of
- * M3U8 playlists.
- *
- */
- import { resolveUrl, resolveManifestRedirect } from './resolve-url';
- import videojs from 'video.js';
- import window from 'global/window';
- import logger from './util/logger';
- import {
- parseManifest,
- addPropertiesToMain,
- mainForMedia,
- setupMediaPlaylist,
- forEachMediaGroup,
- createPlaylistID,
- groupID
- } from './manifest';
- import {getKnownPartCount} from './playlist.js';
- import {merge} from './util/vjs-compat';
- import DateRangesStorage from './util/date-ranges';
- const { EventTarget } = videojs;
- const addLLHLSQueryDirectives = (uri, media) => {
- if (media.endList || !media.serverControl) {
- return uri;
- }
- const parameters = {};
- if (media.serverControl.canBlockReload) {
- const {preloadSegment} = media;
- // next msn is a zero based value, length is not.
- let nextMSN = media.mediaSequence + media.segments.length;
- // If preload segment has parts then it is likely
- // that we are going to request a part of that preload segment.
- // the logic below is used to determine that.
- if (preloadSegment) {
- const parts = preloadSegment.parts || [];
- // _HLS_part is a zero based index
- const nextPart = getKnownPartCount(media) - 1;
- // if nextPart is > -1 and not equal to just the
- // length of parts, then we know we had part preload hints
- // and we need to add the _HLS_part= query
- if (nextPart > -1 && nextPart !== (parts.length - 1)) {
- // add existing parts to our preload hints
- // eslint-disable-next-line
- parameters._HLS_part = nextPart;
- }
- // this if statement makes sure that we request the msn
- // of the preload segment if:
- // 1. the preload segment had parts (and was not yet a full segment)
- // but was added to our segments array
- // 2. the preload segment had preload hints for parts that are not in
- // the manifest yet.
- // in all other cases we want the segment after the preload segment
- // which will be given by using media.segments.length because it is 1 based
- // rather than 0 based.
- if (nextPart > -1 || parts.length) {
- nextMSN--;
- }
- }
- // add _HLS_msn= in front of any _HLS_part query
- // eslint-disable-next-line
- parameters._HLS_msn = nextMSN;
- }
- if (media.serverControl && media.serverControl.canSkipUntil) {
- // add _HLS_skip= infront of all other queries.
- // eslint-disable-next-line
- parameters._HLS_skip = (media.serverControl.canSkipDateranges ? 'v2' : 'YES');
- }
- if (Object.keys(parameters).length) {
- const parsedUri = new window.URL(uri);
- ['_HLS_skip', '_HLS_msn', '_HLS_part'].forEach(function(name) {
- if (!parameters.hasOwnProperty(name)) {
- return;
- }
- parsedUri.searchParams.set(name, parameters[name]);
- });
- uri = parsedUri.toString();
- }
- return uri;
- };
- /**
- * Returns a new segment object with properties and
- * the parts array merged.
- *
- * @param {Object} a the old segment
- * @param {Object} b the new segment
- *
- * @return {Object} the merged segment
- */
- export const updateSegment = (a, b) => {
- if (!a) {
- return b;
- }
- const result = merge(a, b);
- // if only the old segment has preload hints
- // and the new one does not, remove preload hints.
- if (a.preloadHints && !b.preloadHints) {
- delete result.preloadHints;
- }
- // if only the old segment has parts
- // then the parts are no longer valid
- if (a.parts && !b.parts) {
- delete result.parts;
- // if both segments have parts
- // copy part propeties from the old segment
- // to the new one.
- } else if (a.parts && b.parts) {
- for (let i = 0; i < b.parts.length; i++) {
- if (a.parts && a.parts[i]) {
- result.parts[i] = merge(a.parts[i], b.parts[i]);
- }
- }
- }
- // set skipped to false for segments that have
- // have had information merged from the old segment.
- if (!a.skipped && b.skipped) {
- result.skipped = false;
- }
- // set preload to false for segments that have
- // had information added in the new segment.
- if (a.preload && !b.preload) {
- result.preload = false;
- }
- return result;
- };
- /**
- * Returns a new array of segments that is the result of merging
- * properties from an older list of segments onto an updated
- * list. No properties on the updated playlist will be ovewritten.
- *
- * @param {Array} original the outdated list of segments
- * @param {Array} update the updated list of segments
- * @param {number=} offset the index of the first update
- * segment in the original segment list. For non-live playlists,
- * this should always be zero and does not need to be
- * specified. For live playlists, it should be the difference
- * between the media sequence numbers in the original and updated
- * playlists.
- * @return {Array} a list of merged segment objects
- */
- export const updateSegments = (original, update, offset) => {
- const oldSegments = original.slice();
- const newSegments = update.slice();
- offset = offset || 0;
- const result = [];
- let currentMap;
- for (let newIndex = 0; newIndex < newSegments.length; newIndex++) {
- const oldSegment = oldSegments[newIndex + offset];
- const newSegment = newSegments[newIndex];
- if (oldSegment) {
- currentMap = oldSegment.map || currentMap;
- result.push(updateSegment(oldSegment, newSegment));
- } else {
- // carry over map to new segment if it is missing
- if (currentMap && !newSegment.map) {
- newSegment.map = currentMap;
- }
- result.push(newSegment);
- }
- }
- return result;
- };
- export const resolveSegmentUris = (segment, baseUri) => {
- // preloadSegment will not have a uri at all
- // as the segment isn't actually in the manifest yet, only parts
- if (!segment.resolvedUri && segment.uri) {
- segment.resolvedUri = resolveUrl(baseUri, segment.uri);
- }
- if (segment.key && !segment.key.resolvedUri) {
- segment.key.resolvedUri = resolveUrl(baseUri, segment.key.uri);
- }
- if (segment.map && !segment.map.resolvedUri) {
- segment.map.resolvedUri = resolveUrl(baseUri, segment.map.uri);
- }
- if (segment.map && segment.map.key && !segment.map.key.resolvedUri) {
- segment.map.key.resolvedUri = resolveUrl(baseUri, segment.map.key.uri);
- }
- if (segment.parts && segment.parts.length) {
- segment.parts.forEach((p) => {
- if (p.resolvedUri) {
- return;
- }
- p.resolvedUri = resolveUrl(baseUri, p.uri);
- });
- }
- if (segment.preloadHints && segment.preloadHints.length) {
- segment.preloadHints.forEach((p) => {
- if (p.resolvedUri) {
- return;
- }
- p.resolvedUri = resolveUrl(baseUri, p.uri);
- });
- }
- };
- const getAllSegments = function(media) {
- const segments = media.segments || [];
- const preloadSegment = media.preloadSegment;
- // a preloadSegment with only preloadHints is not currently
- // a usable segment, only include a preloadSegment that has
- // parts.
- if (preloadSegment && preloadSegment.parts && preloadSegment.parts.length) {
- // if preloadHints has a MAP that means that the
- // init segment is going to change. We cannot use any of the parts
- // from this preload segment.
- if (preloadSegment.preloadHints) {
- for (let i = 0; i < preloadSegment.preloadHints.length; i++) {
- if (preloadSegment.preloadHints[i].type === 'MAP') {
- return segments;
- }
- }
- }
- // set the duration for our preload segment to target duration.
- preloadSegment.duration = media.targetDuration;
- preloadSegment.preload = true;
- segments.push(preloadSegment);
- }
- return segments;
- };
- // consider the playlist unchanged if the playlist object is the same or
- // the number of segments is equal, the media sequence number is unchanged,
- // and this playlist hasn't become the end of the playlist
- export const isPlaylistUnchanged = (a, b) => a === b ||
- (a.segments && b.segments && a.segments.length === b.segments.length &&
- a.endList === b.endList &&
- a.mediaSequence === b.mediaSequence &&
- a.preloadSegment === b.preloadSegment);
- /**
- * Returns a new main playlist that is the result of merging an
- * updated media playlist into the original version. If the
- * updated media playlist does not match any of the playlist
- * entries in the original main playlist, null is returned.
- *
- * @param {Object} main a parsed main M3U8 object
- * @param {Object} media a parsed media M3U8 object
- * @return {Object} a new object that represents the original
- * main playlist with the updated media playlist merged in, or
- * null if the merge produced no change.
- */
- export const updateMain = (main, newMedia, unchangedCheck = isPlaylistUnchanged) => {
- const result = merge(main, {});
- const oldMedia = result.playlists[newMedia.id];
- if (!oldMedia) {
- return null;
- }
- if (unchangedCheck(oldMedia, newMedia)) {
- return null;
- }
- newMedia.segments = getAllSegments(newMedia);
- const mergedPlaylist = merge(oldMedia, newMedia);
- // always use the new media's preload segment
- if (mergedPlaylist.preloadSegment && !newMedia.preloadSegment) {
- delete mergedPlaylist.preloadSegment;
- }
- // if the update could overlap existing segment information, merge the two segment lists
- if (oldMedia.segments) {
- if (newMedia.skip) {
- newMedia.segments = newMedia.segments || [];
- // add back in objects for skipped segments, so that we merge
- // old properties into the new segments
- for (let i = 0; i < newMedia.skip.skippedSegments; i++) {
- newMedia.segments.unshift({skipped: true});
- }
- }
- mergedPlaylist.segments = updateSegments(
- oldMedia.segments,
- newMedia.segments,
- newMedia.mediaSequence - oldMedia.mediaSequence
- );
- }
- // resolve any segment URIs to prevent us from having to do it later
- mergedPlaylist.segments.forEach((segment) => {
- resolveSegmentUris(segment, mergedPlaylist.resolvedUri);
- });
- // TODO Right now in the playlists array there are two references to each playlist, one
- // that is referenced by index, and one by URI. The index reference may no longer be
- // necessary.
- for (let i = 0; i < result.playlists.length; i++) {
- if (result.playlists[i].id === newMedia.id) {
- result.playlists[i] = mergedPlaylist;
- }
- }
- result.playlists[newMedia.id] = mergedPlaylist;
- // URI reference added for backwards compatibility
- result.playlists[newMedia.uri] = mergedPlaylist;
- // update media group playlist references.
- forEachMediaGroup(main, (properties, mediaType, groupKey, labelKey) => {
- if (!properties.playlists) {
- return;
- }
- for (let i = 0; i < properties.playlists.length; i++) {
- if (newMedia.id === properties.playlists[i].id) {
- properties.playlists[i] = mergedPlaylist;
- }
- }
- });
- return result;
- };
- /**
- * Calculates the time to wait before refreshing a live playlist
- *
- * @param {Object} media
- * The current media
- * @param {boolean} update
- * True if there were any updates from the last refresh, false otherwise
- * @return {number}
- * The time in ms to wait before refreshing the live playlist
- */
- export const refreshDelay = (media, update) => {
- const segments = media.segments || [];
- const lastSegment = segments[segments.length - 1];
- const lastPart = lastSegment && lastSegment.parts && lastSegment.parts[lastSegment.parts.length - 1];
- const lastDuration = lastPart && lastPart.duration || lastSegment && lastSegment.duration;
- if (update && lastDuration) {
- return lastDuration * 1000;
- }
- // if the playlist is unchanged since the last reload or last segment duration
- // cannot be determined, try again after half the target duration
- return (media.partTargetDuration || media.targetDuration || 10) * 500;
- };
- /**
- * Load a playlist from a remote location
- *
- * @class PlaylistLoader
- * @extends Stream
- * @param {string|Object} src url or object of manifest
- * @param {boolean} withCredentials the withCredentials xhr option
- * @class
- */
- export default class PlaylistLoader extends EventTarget {
- constructor(src, vhs, options = { }) {
- super();
- if (!src) {
- throw new Error('A non-empty playlist URL or object is required');
- }
- this.logger_ = logger('PlaylistLoader');
- const { withCredentials = false} = options;
- this.src = src;
- this.vhs_ = vhs;
- this.withCredentials = withCredentials;
- this.addDateRangesToTextTrack_ = options.addDateRangesToTextTrack;
- const vhsOptions = vhs.options_;
- this.customTagParsers = (vhsOptions && vhsOptions.customTagParsers) || [];
- this.customTagMappers = (vhsOptions && vhsOptions.customTagMappers) || [];
- this.llhls = vhsOptions && vhsOptions.llhls;
- this.dateRangesStorage_ = new DateRangesStorage();
- // initialize the loader state
- this.state = 'HAVE_NOTHING';
- // live playlist staleness timeout
- this.handleMediaupdatetimeout_ = this.handleMediaupdatetimeout_.bind(this);
- this.on('mediaupdatetimeout', this.handleMediaupdatetimeout_);
- this.on('loadedplaylist', this.handleLoadedPlaylist_.bind(this));
- }
- handleLoadedPlaylist_() {
- const mediaPlaylist = this.media();
- if (!mediaPlaylist) {
- return;
- }
- this.dateRangesStorage_.setOffset(mediaPlaylist.segments);
- this.dateRangesStorage_.setPendingDateRanges(mediaPlaylist.dateRanges);
- const availableDateRanges = this.dateRangesStorage_.getDateRangesToProcess();
- if (!availableDateRanges.length || !this.addDateRangesToTextTrack_) {
- return;
- }
- this.addDateRangesToTextTrack_(availableDateRanges);
- }
- handleMediaupdatetimeout_() {
- if (this.state !== 'HAVE_METADATA') {
- // only refresh the media playlist if no other activity is going on
- return;
- }
- const media = this.media();
- let uri = resolveUrl(this.main.uri, media.uri);
- if (this.llhls) {
- uri = addLLHLSQueryDirectives(uri, media);
- }
- this.state = 'HAVE_CURRENT_METADATA';
- this.request = this.vhs_.xhr({
- uri,
- withCredentials: this.withCredentials
- }, (error, req) => {
- // disposed
- if (!this.request) {
- return;
- }
- if (error) {
- return this.playlistRequestError(this.request, this.media(), 'HAVE_METADATA');
- }
- this.haveMetadata({
- playlistString: this.request.responseText,
- url: this.media().uri,
- id: this.media().id
- });
- });
- }
- playlistRequestError(xhr, playlist, startingState) {
- const {
- uri,
- id
- } = playlist;
- // any in-flight request is now finished
- this.request = null;
- if (startingState) {
- this.state = startingState;
- }
- this.error = {
- playlist: this.main.playlists[id],
- status: xhr.status,
- message: `HLS playlist request error at URL: ${uri}.`,
- responseText: xhr.responseText,
- code: (xhr.status >= 500) ? 4 : 2
- };
- this.trigger('error');
- }
- parseManifest_({url, manifestString}) {
- return parseManifest({
- onwarn: ({message}) => this.logger_(`m3u8-parser warn for ${url}: ${message}`),
- oninfo: ({message}) => this.logger_(`m3u8-parser info for ${url}: ${message}`),
- manifestString,
- customTagParsers: this.customTagParsers,
- customTagMappers: this.customTagMappers,
- llhls: this.llhls
- });
- }
- /**
- * Update the playlist loader's state in response to a new or updated playlist.
- *
- * @param {string} [playlistString]
- * Playlist string (if playlistObject is not provided)
- * @param {Object} [playlistObject]
- * Playlist object (if playlistString is not provided)
- * @param {string} url
- * URL of playlist
- * @param {string} id
- * ID to use for playlist
- */
- haveMetadata({ playlistString, playlistObject, url, id }) {
- // any in-flight request is now finished
- this.request = null;
- this.state = 'HAVE_METADATA';
- const playlist = playlistObject || this.parseManifest_({
- url,
- manifestString: playlistString
- });
- playlist.lastRequest = Date.now();
- setupMediaPlaylist({
- playlist,
- uri: url,
- id
- });
- // merge this playlist into the main manifest
- const update = updateMain(this.main, playlist);
- this.targetDuration = playlist.partTargetDuration || playlist.targetDuration;
- this.pendingMedia_ = null;
- if (update) {
- this.main = update;
- this.media_ = this.main.playlists[id];
- } else {
- this.trigger('playlistunchanged');
- }
- this.updateMediaUpdateTimeout_(refreshDelay(this.media(), !!update));
- this.trigger('loadedplaylist');
- }
- /**
- * Abort any outstanding work and clean up.
- */
- dispose() {
- this.trigger('dispose');
- this.stopRequest();
- window.clearTimeout(this.mediaUpdateTimeout);
- window.clearTimeout(this.finalRenditionTimeout);
- this.dateRangesStorage_ = new DateRangesStorage();
- this.off();
- }
- stopRequest() {
- if (this.request) {
- const oldRequest = this.request;
- this.request = null;
- oldRequest.onreadystatechange = null;
- oldRequest.abort();
- }
- }
- /**
- * When called without any arguments, returns the currently
- * active media playlist. When called with a single argument,
- * triggers the playlist loader to asynchronously switch to the
- * specified media playlist. Calling this method while the
- * loader is in the HAVE_NOTHING causes an error to be emitted
- * but otherwise has no effect.
- *
- * @param {Object=} playlist the parsed media playlist
- * object to switch to
- * @param {boolean=} shouldDelay whether we should delay the request by half target duration
- *
- * @return {Playlist} the current loaded media
- */
- media(playlist, shouldDelay) {
- // getter
- if (!playlist) {
- return this.media_;
- }
- // setter
- if (this.state === 'HAVE_NOTHING') {
- throw new Error('Cannot switch media playlist from ' + this.state);
- }
- // find the playlist object if the target playlist has been
- // specified by URI
- if (typeof playlist === 'string') {
- if (!this.main.playlists[playlist]) {
- throw new Error('Unknown playlist URI: ' + playlist);
- }
- playlist = this.main.playlists[playlist];
- }
- window.clearTimeout(this.finalRenditionTimeout);
- if (shouldDelay) {
- const delay = ((playlist.partTargetDuration || playlist.targetDuration) / 2) * 1000 || 5 * 1000;
- this.finalRenditionTimeout =
- window.setTimeout(this.media.bind(this, playlist, false), delay);
- return;
- }
- const startingState = this.state;
- const mediaChange = !this.media_ || playlist.id !== this.media_.id;
- const mainPlaylistRef = this.main.playlists[playlist.id];
- // switch to fully loaded playlists immediately
- if (mainPlaylistRef && mainPlaylistRef.endList ||
- // handle the case of a playlist object (e.g., if using vhs-json with a resolved
- // media playlist or, for the case of demuxed audio, a resolved audio media group)
- (playlist.endList && playlist.segments.length)) {
- // abort outstanding playlist requests
- if (this.request) {
- this.request.onreadystatechange = null;
- this.request.abort();
- this.request = null;
- }
- this.state = 'HAVE_METADATA';
- this.media_ = playlist;
- // trigger media change if the active media has been updated
- if (mediaChange) {
- this.trigger('mediachanging');
- if (startingState === 'HAVE_MAIN_MANIFEST') {
- // The initial playlist was a main manifest, and the first media selected was
- // also provided (in the form of a resolved playlist object) as part of the
- // source object (rather than just a URL). Therefore, since the media playlist
- // doesn't need to be requested, loadedmetadata won't trigger as part of the
- // normal flow, and needs an explicit trigger here.
- this.trigger('loadedmetadata');
- } else {
- this.trigger('mediachange');
- }
- }
- return;
- }
- // We update/set the timeout here so that live playlists
- // that are not a media change will "start" the loader as expected.
- // We expect that this function will start the media update timeout
- // cycle again. This also prevents a playlist switch failure from
- // causing us to stall during live.
- this.updateMediaUpdateTimeout_(refreshDelay(playlist, true));
- // switching to the active playlist is a no-op
- if (!mediaChange) {
- return;
- }
- this.state = 'SWITCHING_MEDIA';
- // there is already an outstanding playlist request
- if (this.request) {
- if (playlist.resolvedUri === this.request.url) {
- // requesting to switch to the same playlist multiple times
- // has no effect after the first
- return;
- }
- this.request.onreadystatechange = null;
- this.request.abort();
- this.request = null;
- }
- // request the new playlist
- if (this.media_) {
- this.trigger('mediachanging');
- }
- this.pendingMedia_ = playlist;
- this.request = this.vhs_.xhr({
- uri: playlist.resolvedUri,
- withCredentials: this.withCredentials
- }, (error, req) => {
- // disposed
- if (!this.request) {
- return;
- }
- playlist.lastRequest = Date.now();
- playlist.resolvedUri = resolveManifestRedirect(playlist.resolvedUri, req);
- if (error) {
- return this.playlistRequestError(this.request, playlist, startingState);
- }
- this.haveMetadata({
- playlistString: req.responseText,
- url: playlist.uri,
- id: playlist.id
- });
- // fire loadedmetadata the first time a media playlist is loaded
- if (startingState === 'HAVE_MAIN_MANIFEST') {
- this.trigger('loadedmetadata');
- } else {
- this.trigger('mediachange');
- }
- });
- }
- /**
- * pause loading of the playlist
- */
- pause() {
- if (this.mediaUpdateTimeout) {
- window.clearTimeout(this.mediaUpdateTimeout);
- this.mediaUpdateTimeout = null;
- }
- this.stopRequest();
- if (this.state === 'HAVE_NOTHING') {
- // If we pause the loader before any data has been retrieved, its as if we never
- // started, so reset to an unstarted state.
- this.started = false;
- }
- // Need to restore state now that no activity is happening
- if (this.state === 'SWITCHING_MEDIA') {
- // if the loader was in the process of switching media, it should either return to
- // HAVE_MAIN_MANIFEST or HAVE_METADATA depending on if the loader has loaded a media
- // playlist yet. This is determined by the existence of loader.media_
- if (this.media_) {
- this.state = 'HAVE_METADATA';
- } else {
- this.state = 'HAVE_MAIN_MANIFEST';
- }
- } else if (this.state === 'HAVE_CURRENT_METADATA') {
- this.state = 'HAVE_METADATA';
- }
- }
- /**
- * start loading of the playlist
- */
- load(shouldDelay) {
- if (this.mediaUpdateTimeout) {
- window.clearTimeout(this.mediaUpdateTimeout);
- this.mediaUpdateTimeout = null;
- }
- const media = this.media();
- if (shouldDelay) {
- const delay = media ? ((media.partTargetDuration || media.targetDuration) / 2) * 1000 : 5 * 1000;
- this.mediaUpdateTimeout = window.setTimeout(() => {
- this.mediaUpdateTimeout = null;
- this.load();
- }, delay);
- return;
- }
- if (!this.started) {
- this.start();
- return;
- }
- if (media && !media.endList) {
- this.trigger('mediaupdatetimeout');
- } else {
- this.trigger('loadedplaylist');
- }
- }
- updateMediaUpdateTimeout_(delay) {
- if (this.mediaUpdateTimeout) {
- window.clearTimeout(this.mediaUpdateTimeout);
- this.mediaUpdateTimeout = null;
- }
- // we only have use mediaupdatetimeout for live playlists.
- if (!this.media() || this.media().endList) {
- return;
- }
- this.mediaUpdateTimeout = window.setTimeout(() => {
- this.mediaUpdateTimeout = null;
- this.trigger('mediaupdatetimeout');
- this.updateMediaUpdateTimeout_(delay);
- }, delay);
- }
- /**
- * start loading of the playlist
- */
- start() {
- this.started = true;
- if (typeof this.src === 'object') {
- // in the case of an entirely constructed manifest object (meaning there's no actual
- // manifest on a server), default the uri to the page's href
- if (!this.src.uri) {
- this.src.uri = window.location.href;
- }
- // resolvedUri is added on internally after the initial request. Since there's no
- // request for pre-resolved manifests, add on resolvedUri here.
- this.src.resolvedUri = this.src.uri;
- // Since a manifest object was passed in as the source (instead of a URL), the first
- // request can be skipped (since the top level of the manifest, at a minimum, is
- // already available as a parsed manifest object). However, if the manifest object
- // represents a main playlist, some media playlists may need to be resolved before
- // the starting segment list is available. Therefore, go directly to setup of the
- // initial playlist, and let the normal flow continue from there.
- //
- // Note that the call to setup is asynchronous, as other sections of VHS may assume
- // that the first request is asynchronous.
- setTimeout(() => {
- this.setupInitialPlaylist(this.src);
- }, 0);
- return;
- }
- // request the specified URL
- this.request = this.vhs_.xhr({
- uri: this.src,
- 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: `HLS playlist request error at URL: ${this.src}.`,
- responseText: req.responseText,
- // MEDIA_ERR_NETWORK
- code: 2
- };
- if (this.state === 'HAVE_NOTHING') {
- this.started = false;
- }
- return this.trigger('error');
- }
- this.src = resolveManifestRedirect(this.src, req);
- const manifest = this.parseManifest_({
- manifestString: req.responseText,
- url: this.src
- });
- this.setupInitialPlaylist(manifest);
- });
- }
- srcUri() {
- return typeof this.src === 'string' ? this.src : this.src.uri;
- }
- /**
- * Given a manifest object that's either a main or media playlist, trigger the proper
- * events and set the state of the playlist loader.
- *
- * If the manifest object represents a main playlist, `loadedplaylist` will be
- * triggered to allow listeners to select a playlist. If none is selected, the loader
- * will default to the first one in the playlists array.
- *
- * If the manifest object represents a media playlist, `loadedplaylist` will be
- * triggered followed by `loadedmetadata`, as the only available playlist is loaded.
- *
- * In the case of a media playlist, a main playlist object wrapper with one playlist
- * will be created so that all logic can handle playlists in the same fashion (as an
- * assumed manifest object schema).
- *
- * @param {Object} manifest
- * The parsed manifest object
- */
- setupInitialPlaylist(manifest) {
- this.state = 'HAVE_MAIN_MANIFEST';
- if (manifest.playlists) {
- this.main = manifest;
- addPropertiesToMain(this.main, this.srcUri());
- // If the initial main playlist has playlists wtih segments already resolved,
- // then resolve URIs in advance, as they are usually done after a playlist request,
- // which may not happen if the playlist is resolved.
- manifest.playlists.forEach((playlist) => {
- playlist.segments = getAllSegments(playlist);
- playlist.segments.forEach((segment) => {
- resolveSegmentUris(segment, playlist.resolvedUri);
- });
- });
- this.trigger('loadedplaylist');
- if (!this.request) {
- // no media playlist was specifically selected so start
- // from the first listed one
- this.media(this.main.playlists[0]);
- }
- return;
- }
- // In order to support media playlists passed in as vhs-json, the case where the uri
- // is not provided as part of the manifest should be considered, and an appropriate
- // default used.
- const uri = this.srcUri() || window.location.href;
- this.main = mainForMedia(manifest, uri);
- this.haveMetadata({
- playlistObject: manifest,
- url: uri,
- id: this.main.playlists[0].id
- });
- this.trigger('loadedmetadata');
- }
- /**
- * Updates or deletes a preexisting pathway clone.
- * Ensures that all playlists related to the old pathway clone are
- * either updated or deleted.
- *
- * @param {Object} clone On update, the pathway clone object for the newly updated pathway clone.
- * On delete, the old pathway clone object to be deleted.
- * @param {boolean} isUpdate True if the pathway is to be updated,
- * false if it is meant to be deleted.
- */
- updateOrDeleteClone(clone, isUpdate) {
- const main = this.main;
- const pathway = clone.ID;
- let i = main.playlists.length;
- // Iterate backwards through the playlist so we can remove playlists if necessary.
- while (i--) {
- const p = main.playlists[i];
- if (p.attributes['PATHWAY-ID'] === pathway) {
- const oldPlaylistUri = p.resolvedUri;
- const oldPlaylistId = p.id;
- // update the indexed playlist and add new playlists by ID and URI
- if (isUpdate) {
- const newPlaylistUri = this.createCloneURI_(p.resolvedUri, clone);
- const newPlaylistId = createPlaylistID(pathway, newPlaylistUri);
- const attributes = this.createCloneAttributes_(pathway, p.attributes);
- const updatedPlaylist = this.createClonePlaylist_(p, newPlaylistId, clone, attributes);
- main.playlists[i] = updatedPlaylist;
- main.playlists[newPlaylistId] = updatedPlaylist;
- main.playlists[newPlaylistUri] = updatedPlaylist;
- } else {
- // Remove the indexed playlist.
- main.playlists.splice(i, 1);
- }
- // Remove playlists by the old ID and URI.
- delete main.playlists[oldPlaylistId];
- delete main.playlists[oldPlaylistUri];
- }
- }
- this.updateOrDeleteCloneMedia(clone, isUpdate);
- }
- /**
- * Updates or deletes media data based on the pathway clone object.
- * Due to the complexity of the media groups and playlists, in all cases
- * we remove all of the old media groups and playlists.
- * On updates, we then create new media groups and playlists based on the
- * new pathway clone object.
- *
- * @param {Object} clone The pathway clone object for the newly updated pathway clone.
- * @param {boolean} isUpdate True if the pathway is to be updated,
- * false if it is meant to be deleted.
- */
- updateOrDeleteCloneMedia(clone, isUpdate) {
- const main = this.main;
- const id = clone.ID;
- ['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((mediaType) => {
- if (!main.mediaGroups[mediaType] || !main.mediaGroups[mediaType][id]) {
- return;
- }
- for (const groupKey in main.mediaGroups[mediaType]) {
- // Remove all media playlists for the media group for this pathway clone.
- if (groupKey === id) {
- for (const labelKey in main.mediaGroups[mediaType][groupKey]) {
- const oldMedia = main.mediaGroups[mediaType][groupKey][labelKey];
- oldMedia.playlists.forEach((p, i) => {
- const oldMediaPlaylist = main.playlists[p.id];
- const oldPlaylistId = oldMediaPlaylist.id;
- const oldPlaylistUri = oldMediaPlaylist.resolvedUri;
- delete main.playlists[oldPlaylistId];
- delete main.playlists[oldPlaylistUri];
- });
- }
- // Delete the old media group.
- delete main.mediaGroups[mediaType][groupKey];
- }
- }
- });
- // Create the new media groups and playlists if there is an update.
- if (isUpdate) {
- this.createClonedMediaGroups_(clone);
- }
- }
- /**
- * Given a pathway clone object, clones all necessary playlists.
- *
- * @param {Object} clone The pathway clone object.
- * @param {Object} basePlaylist The original playlist to clone from.
- */
- addClonePathway(clone, basePlaylist = {}) {
- const main = this.main;
- const index = main.playlists.length;
- const uri = this.createCloneURI_(basePlaylist.resolvedUri, clone);
- const playlistId = createPlaylistID(clone.ID, uri);
- const attributes = this.createCloneAttributes_(clone.ID, basePlaylist.attributes);
- const playlist = this.createClonePlaylist_(basePlaylist, playlistId, clone, attributes);
- main.playlists[index] = playlist;
- // add playlist by ID and URI
- main.playlists[playlistId] = playlist;
- main.playlists[uri] = playlist;
- this.createClonedMediaGroups_(clone);
- }
- /**
- * Given a pathway clone object we create clones of all media.
- * In this function, all necessary information and updated playlists
- * are added to the `mediaGroup` object.
- * Playlists are also added to the `playlists` array so the media groups
- * will be properly linked.
- *
- * @param {Object} clone The pathway clone object.
- */
- createClonedMediaGroups_(clone) {
- const id = clone.ID;
- const baseID = clone['BASE-ID'];
- const main = this.main;
- ['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((mediaType) => {
- // If the media type doesn't exist, or there is already a clone, skip
- // to the next media type.
- if (!main.mediaGroups[mediaType] || main.mediaGroups[mediaType][id]) {
- return;
- }
- for (const groupKey in main.mediaGroups[mediaType]) {
- if (groupKey === baseID) {
- // Create the group.
- main.mediaGroups[mediaType][id] = {};
- } else {
- // There is no need to iterate over label keys in this case.
- continue;
- }
- for (const labelKey in main.mediaGroups[mediaType][groupKey]) {
- const oldMedia = main.mediaGroups[mediaType][groupKey][labelKey];
- main.mediaGroups[mediaType][id][labelKey] = Object.assign({}, oldMedia);
- const newMedia = main.mediaGroups[mediaType][id][labelKey];
- // update URIs on the media
- const newUri = this.createCloneURI_(oldMedia.resolvedUri, clone);
- newMedia.resolvedUri = newUri;
- newMedia.uri = newUri;
- // Reset playlists in the new media group.
- newMedia.playlists = [];
- // Create new playlists in the newly cloned media group.
- oldMedia.playlists.forEach((p, i) => {
- const oldMediaPlaylist = main.playlists[p.id];
- const group = groupID(mediaType, id, labelKey);
- const newPlaylistID = createPlaylistID(id, group);
- // Check to see if it already exists
- if (oldMediaPlaylist && !main.playlists[newPlaylistID]) {
- const newMediaPlaylist = this.createClonePlaylist_(oldMediaPlaylist, newPlaylistID, clone);
- const newPlaylistUri = newMediaPlaylist.resolvedUri;
- main.playlists[newPlaylistID] = newMediaPlaylist;
- main.playlists[newPlaylistUri] = newMediaPlaylist;
- }
- newMedia.playlists[i] = this.createClonePlaylist_(p, newPlaylistID, clone);
- });
- }
- }
- });
- }
- /**
- * Using the original playlist to be cloned, and the pathway clone object
- * information, we create a new playlist.
- *
- * @param {Object} basePlaylist The original playlist to be cloned from.
- * @param {string} id The desired id of the newly cloned playlist.
- * @param {Object} clone The pathway clone object.
- * @param {Object} attributes An optional object to populate the `attributes` property in the playlist.
- *
- * @return {Object} The combined cloned playlist.
- */
- createClonePlaylist_(basePlaylist, id, clone, attributes) {
- const uri = this.createCloneURI_(basePlaylist.resolvedUri, clone);
- const newProps = {
- resolvedUri: uri,
- uri,
- id
- };
- // Remove all segments from previous playlist in the clone.
- if (basePlaylist.segments) {
- newProps.segments = [];
- }
- if (attributes) {
- newProps.attributes = attributes;
- }
- return merge(basePlaylist, newProps);
- }
- /**
- * Generates an updated URI for a cloned pathway based on the original
- * pathway's URI and the paramaters from the pathway clone object in the
- * content steering server response.
- *
- * @param {string} baseUri URI to be updated in the cloned pathway.
- * @param {Object} clone The pathway clone object.
- *
- * @return {string} The updated URI for the cloned pathway.
- */
- createCloneURI_(baseURI, clone) {
- const uri = new URL(baseURI);
- uri.hostname = clone['URI-REPLACEMENT'].HOST;
- const params = clone['URI-REPLACEMENT'].PARAMS;
- // Add params to the cloned URL.
- for (const key of Object.keys(params)) {
- uri.searchParams.set(key, params[key]);
- }
- return uri.href;
- }
- /**
- * Helper function to create the attributes needed for the new clone.
- * This mainly adds the necessary media attributes.
- *
- * @param {string} id The pathway clone object ID.
- * @param {Object} oldAttributes The old attributes to compare to.
- * @return {Object} The new attributes to add to the playlist.
- */
- createCloneAttributes_(id, oldAttributes) {
- const attributes = { ['PATHWAY-ID']: id };
- ['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((mediaType) => {
- if (oldAttributes[mediaType]) {
- attributes[mediaType] = id;
- }
- });
- return attributes;
- }
- /**
- * Returns the key ID set from a playlist
- *
- * @param {playlist} playlist to fetch the key ID set from.
- * @return a Set of 32 digit hex strings that represent the unique keyIds for that playlist.
- */
- getKeyIdSet(playlist) {
- if (playlist.contentProtection) {
- const keyIds = new Set();
- for (const keysystem in playlist.contentProtection) {
- const keyId = playlist.contentProtection[keysystem].attributes.keyId;
- if (keyId) {
- keyIds.add(keyId.toLowerCase());
- }
- }
- return keyIds;
- }
- }
- }
|