playlist-loader.js 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218
  1. /**
  2. * @file playlist-loader.js
  3. *
  4. * A state machine that manages the loading, caching, and updating of
  5. * M3U8 playlists.
  6. *
  7. */
  8. import { resolveUrl, resolveManifestRedirect } from './resolve-url';
  9. import videojs from 'video.js';
  10. import window from 'global/window';
  11. import logger from './util/logger';
  12. import {
  13. parseManifest,
  14. addPropertiesToMain,
  15. mainForMedia,
  16. setupMediaPlaylist,
  17. forEachMediaGroup,
  18. createPlaylistID,
  19. groupID
  20. } from './manifest';
  21. import {getKnownPartCount} from './playlist.js';
  22. import {merge} from './util/vjs-compat';
  23. import DateRangesStorage from './util/date-ranges';
  24. const { EventTarget } = videojs;
  25. const addLLHLSQueryDirectives = (uri, media) => {
  26. if (media.endList || !media.serverControl) {
  27. return uri;
  28. }
  29. const parameters = {};
  30. if (media.serverControl.canBlockReload) {
  31. const {preloadSegment} = media;
  32. // next msn is a zero based value, length is not.
  33. let nextMSN = media.mediaSequence + media.segments.length;
  34. // If preload segment has parts then it is likely
  35. // that we are going to request a part of that preload segment.
  36. // the logic below is used to determine that.
  37. if (preloadSegment) {
  38. const parts = preloadSegment.parts || [];
  39. // _HLS_part is a zero based index
  40. const nextPart = getKnownPartCount(media) - 1;
  41. // if nextPart is > -1 and not equal to just the
  42. // length of parts, then we know we had part preload hints
  43. // and we need to add the _HLS_part= query
  44. if (nextPart > -1 && nextPart !== (parts.length - 1)) {
  45. // add existing parts to our preload hints
  46. // eslint-disable-next-line
  47. parameters._HLS_part = nextPart;
  48. }
  49. // this if statement makes sure that we request the msn
  50. // of the preload segment if:
  51. // 1. the preload segment had parts (and was not yet a full segment)
  52. // but was added to our segments array
  53. // 2. the preload segment had preload hints for parts that are not in
  54. // the manifest yet.
  55. // in all other cases we want the segment after the preload segment
  56. // which will be given by using media.segments.length because it is 1 based
  57. // rather than 0 based.
  58. if (nextPart > -1 || parts.length) {
  59. nextMSN--;
  60. }
  61. }
  62. // add _HLS_msn= in front of any _HLS_part query
  63. // eslint-disable-next-line
  64. parameters._HLS_msn = nextMSN;
  65. }
  66. if (media.serverControl && media.serverControl.canSkipUntil) {
  67. // add _HLS_skip= infront of all other queries.
  68. // eslint-disable-next-line
  69. parameters._HLS_skip = (media.serverControl.canSkipDateranges ? 'v2' : 'YES');
  70. }
  71. if (Object.keys(parameters).length) {
  72. const parsedUri = new window.URL(uri);
  73. ['_HLS_skip', '_HLS_msn', '_HLS_part'].forEach(function(name) {
  74. if (!parameters.hasOwnProperty(name)) {
  75. return;
  76. }
  77. parsedUri.searchParams.set(name, parameters[name]);
  78. });
  79. uri = parsedUri.toString();
  80. }
  81. return uri;
  82. };
  83. /**
  84. * Returns a new segment object with properties and
  85. * the parts array merged.
  86. *
  87. * @param {Object} a the old segment
  88. * @param {Object} b the new segment
  89. *
  90. * @return {Object} the merged segment
  91. */
  92. export const updateSegment = (a, b) => {
  93. if (!a) {
  94. return b;
  95. }
  96. const result = merge(a, b);
  97. // if only the old segment has preload hints
  98. // and the new one does not, remove preload hints.
  99. if (a.preloadHints && !b.preloadHints) {
  100. delete result.preloadHints;
  101. }
  102. // if only the old segment has parts
  103. // then the parts are no longer valid
  104. if (a.parts && !b.parts) {
  105. delete result.parts;
  106. // if both segments have parts
  107. // copy part propeties from the old segment
  108. // to the new one.
  109. } else if (a.parts && b.parts) {
  110. for (let i = 0; i < b.parts.length; i++) {
  111. if (a.parts && a.parts[i]) {
  112. result.parts[i] = merge(a.parts[i], b.parts[i]);
  113. }
  114. }
  115. }
  116. // set skipped to false for segments that have
  117. // have had information merged from the old segment.
  118. if (!a.skipped && b.skipped) {
  119. result.skipped = false;
  120. }
  121. // set preload to false for segments that have
  122. // had information added in the new segment.
  123. if (a.preload && !b.preload) {
  124. result.preload = false;
  125. }
  126. return result;
  127. };
  128. /**
  129. * Returns a new array of segments that is the result of merging
  130. * properties from an older list of segments onto an updated
  131. * list. No properties on the updated playlist will be ovewritten.
  132. *
  133. * @param {Array} original the outdated list of segments
  134. * @param {Array} update the updated list of segments
  135. * @param {number=} offset the index of the first update
  136. * segment in the original segment list. For non-live playlists,
  137. * this should always be zero and does not need to be
  138. * specified. For live playlists, it should be the difference
  139. * between the media sequence numbers in the original and updated
  140. * playlists.
  141. * @return {Array} a list of merged segment objects
  142. */
  143. export const updateSegments = (original, update, offset) => {
  144. const oldSegments = original.slice();
  145. const newSegments = update.slice();
  146. offset = offset || 0;
  147. const result = [];
  148. let currentMap;
  149. for (let newIndex = 0; newIndex < newSegments.length; newIndex++) {
  150. const oldSegment = oldSegments[newIndex + offset];
  151. const newSegment = newSegments[newIndex];
  152. if (oldSegment) {
  153. currentMap = oldSegment.map || currentMap;
  154. result.push(updateSegment(oldSegment, newSegment));
  155. } else {
  156. // carry over map to new segment if it is missing
  157. if (currentMap && !newSegment.map) {
  158. newSegment.map = currentMap;
  159. }
  160. result.push(newSegment);
  161. }
  162. }
  163. return result;
  164. };
  165. export const resolveSegmentUris = (segment, baseUri) => {
  166. // preloadSegment will not have a uri at all
  167. // as the segment isn't actually in the manifest yet, only parts
  168. if (!segment.resolvedUri && segment.uri) {
  169. segment.resolvedUri = resolveUrl(baseUri, segment.uri);
  170. }
  171. if (segment.key && !segment.key.resolvedUri) {
  172. segment.key.resolvedUri = resolveUrl(baseUri, segment.key.uri);
  173. }
  174. if (segment.map && !segment.map.resolvedUri) {
  175. segment.map.resolvedUri = resolveUrl(baseUri, segment.map.uri);
  176. }
  177. if (segment.map && segment.map.key && !segment.map.key.resolvedUri) {
  178. segment.map.key.resolvedUri = resolveUrl(baseUri, segment.map.key.uri);
  179. }
  180. if (segment.parts && segment.parts.length) {
  181. segment.parts.forEach((p) => {
  182. if (p.resolvedUri) {
  183. return;
  184. }
  185. p.resolvedUri = resolveUrl(baseUri, p.uri);
  186. });
  187. }
  188. if (segment.preloadHints && segment.preloadHints.length) {
  189. segment.preloadHints.forEach((p) => {
  190. if (p.resolvedUri) {
  191. return;
  192. }
  193. p.resolvedUri = resolveUrl(baseUri, p.uri);
  194. });
  195. }
  196. };
  197. const getAllSegments = function(media) {
  198. const segments = media.segments || [];
  199. const preloadSegment = media.preloadSegment;
  200. // a preloadSegment with only preloadHints is not currently
  201. // a usable segment, only include a preloadSegment that has
  202. // parts.
  203. if (preloadSegment && preloadSegment.parts && preloadSegment.parts.length) {
  204. // if preloadHints has a MAP that means that the
  205. // init segment is going to change. We cannot use any of the parts
  206. // from this preload segment.
  207. if (preloadSegment.preloadHints) {
  208. for (let i = 0; i < preloadSegment.preloadHints.length; i++) {
  209. if (preloadSegment.preloadHints[i].type === 'MAP') {
  210. return segments;
  211. }
  212. }
  213. }
  214. // set the duration for our preload segment to target duration.
  215. preloadSegment.duration = media.targetDuration;
  216. preloadSegment.preload = true;
  217. segments.push(preloadSegment);
  218. }
  219. return segments;
  220. };
  221. // consider the playlist unchanged if the playlist object is the same or
  222. // the number of segments is equal, the media sequence number is unchanged,
  223. // and this playlist hasn't become the end of the playlist
  224. export const isPlaylistUnchanged = (a, b) => a === b ||
  225. (a.segments && b.segments && a.segments.length === b.segments.length &&
  226. a.endList === b.endList &&
  227. a.mediaSequence === b.mediaSequence &&
  228. a.preloadSegment === b.preloadSegment);
  229. /**
  230. * Returns a new main playlist that is the result of merging an
  231. * updated media playlist into the original version. If the
  232. * updated media playlist does not match any of the playlist
  233. * entries in the original main playlist, null is returned.
  234. *
  235. * @param {Object} main a parsed main M3U8 object
  236. * @param {Object} media a parsed media M3U8 object
  237. * @return {Object} a new object that represents the original
  238. * main playlist with the updated media playlist merged in, or
  239. * null if the merge produced no change.
  240. */
  241. export const updateMain = (main, newMedia, unchangedCheck = isPlaylistUnchanged) => {
  242. const result = merge(main, {});
  243. const oldMedia = result.playlists[newMedia.id];
  244. if (!oldMedia) {
  245. return null;
  246. }
  247. if (unchangedCheck(oldMedia, newMedia)) {
  248. return null;
  249. }
  250. newMedia.segments = getAllSegments(newMedia);
  251. const mergedPlaylist = merge(oldMedia, newMedia);
  252. // always use the new media's preload segment
  253. if (mergedPlaylist.preloadSegment && !newMedia.preloadSegment) {
  254. delete mergedPlaylist.preloadSegment;
  255. }
  256. // if the update could overlap existing segment information, merge the two segment lists
  257. if (oldMedia.segments) {
  258. if (newMedia.skip) {
  259. newMedia.segments = newMedia.segments || [];
  260. // add back in objects for skipped segments, so that we merge
  261. // old properties into the new segments
  262. for (let i = 0; i < newMedia.skip.skippedSegments; i++) {
  263. newMedia.segments.unshift({skipped: true});
  264. }
  265. }
  266. mergedPlaylist.segments = updateSegments(
  267. oldMedia.segments,
  268. newMedia.segments,
  269. newMedia.mediaSequence - oldMedia.mediaSequence
  270. );
  271. }
  272. // resolve any segment URIs to prevent us from having to do it later
  273. mergedPlaylist.segments.forEach((segment) => {
  274. resolveSegmentUris(segment, mergedPlaylist.resolvedUri);
  275. });
  276. // TODO Right now in the playlists array there are two references to each playlist, one
  277. // that is referenced by index, and one by URI. The index reference may no longer be
  278. // necessary.
  279. for (let i = 0; i < result.playlists.length; i++) {
  280. if (result.playlists[i].id === newMedia.id) {
  281. result.playlists[i] = mergedPlaylist;
  282. }
  283. }
  284. result.playlists[newMedia.id] = mergedPlaylist;
  285. // URI reference added for backwards compatibility
  286. result.playlists[newMedia.uri] = mergedPlaylist;
  287. // update media group playlist references.
  288. forEachMediaGroup(main, (properties, mediaType, groupKey, labelKey) => {
  289. if (!properties.playlists) {
  290. return;
  291. }
  292. for (let i = 0; i < properties.playlists.length; i++) {
  293. if (newMedia.id === properties.playlists[i].id) {
  294. properties.playlists[i] = mergedPlaylist;
  295. }
  296. }
  297. });
  298. return result;
  299. };
  300. /**
  301. * Calculates the time to wait before refreshing a live playlist
  302. *
  303. * @param {Object} media
  304. * The current media
  305. * @param {boolean} update
  306. * True if there were any updates from the last refresh, false otherwise
  307. * @return {number}
  308. * The time in ms to wait before refreshing the live playlist
  309. */
  310. export const refreshDelay = (media, update) => {
  311. const segments = media.segments || [];
  312. const lastSegment = segments[segments.length - 1];
  313. const lastPart = lastSegment && lastSegment.parts && lastSegment.parts[lastSegment.parts.length - 1];
  314. const lastDuration = lastPart && lastPart.duration || lastSegment && lastSegment.duration;
  315. if (update && lastDuration) {
  316. return lastDuration * 1000;
  317. }
  318. // if the playlist is unchanged since the last reload or last segment duration
  319. // cannot be determined, try again after half the target duration
  320. return (media.partTargetDuration || media.targetDuration || 10) * 500;
  321. };
  322. /**
  323. * Load a playlist from a remote location
  324. *
  325. * @class PlaylistLoader
  326. * @extends Stream
  327. * @param {string|Object} src url or object of manifest
  328. * @param {boolean} withCredentials the withCredentials xhr option
  329. * @class
  330. */
  331. export default class PlaylistLoader extends EventTarget {
  332. constructor(src, vhs, options = { }) {
  333. super();
  334. if (!src) {
  335. throw new Error('A non-empty playlist URL or object is required');
  336. }
  337. this.logger_ = logger('PlaylistLoader');
  338. const { withCredentials = false} = options;
  339. this.src = src;
  340. this.vhs_ = vhs;
  341. this.withCredentials = withCredentials;
  342. this.addDateRangesToTextTrack_ = options.addDateRangesToTextTrack;
  343. const vhsOptions = vhs.options_;
  344. this.customTagParsers = (vhsOptions && vhsOptions.customTagParsers) || [];
  345. this.customTagMappers = (vhsOptions && vhsOptions.customTagMappers) || [];
  346. this.llhls = vhsOptions && vhsOptions.llhls;
  347. this.dateRangesStorage_ = new DateRangesStorage();
  348. // initialize the loader state
  349. this.state = 'HAVE_NOTHING';
  350. // live playlist staleness timeout
  351. this.handleMediaupdatetimeout_ = this.handleMediaupdatetimeout_.bind(this);
  352. this.on('mediaupdatetimeout', this.handleMediaupdatetimeout_);
  353. this.on('loadedplaylist', this.handleLoadedPlaylist_.bind(this));
  354. }
  355. handleLoadedPlaylist_() {
  356. const mediaPlaylist = this.media();
  357. if (!mediaPlaylist) {
  358. return;
  359. }
  360. this.dateRangesStorage_.setOffset(mediaPlaylist.segments);
  361. this.dateRangesStorage_.setPendingDateRanges(mediaPlaylist.dateRanges);
  362. const availableDateRanges = this.dateRangesStorage_.getDateRangesToProcess();
  363. if (!availableDateRanges.length || !this.addDateRangesToTextTrack_) {
  364. return;
  365. }
  366. this.addDateRangesToTextTrack_(availableDateRanges);
  367. }
  368. handleMediaupdatetimeout_() {
  369. if (this.state !== 'HAVE_METADATA') {
  370. // only refresh the media playlist if no other activity is going on
  371. return;
  372. }
  373. const media = this.media();
  374. let uri = resolveUrl(this.main.uri, media.uri);
  375. if (this.llhls) {
  376. uri = addLLHLSQueryDirectives(uri, media);
  377. }
  378. this.state = 'HAVE_CURRENT_METADATA';
  379. this.request = this.vhs_.xhr({
  380. uri,
  381. withCredentials: this.withCredentials
  382. }, (error, req) => {
  383. // disposed
  384. if (!this.request) {
  385. return;
  386. }
  387. if (error) {
  388. return this.playlistRequestError(this.request, this.media(), 'HAVE_METADATA');
  389. }
  390. this.haveMetadata({
  391. playlistString: this.request.responseText,
  392. url: this.media().uri,
  393. id: this.media().id
  394. });
  395. });
  396. }
  397. playlistRequestError(xhr, playlist, startingState) {
  398. const {
  399. uri,
  400. id
  401. } = playlist;
  402. // any in-flight request is now finished
  403. this.request = null;
  404. if (startingState) {
  405. this.state = startingState;
  406. }
  407. this.error = {
  408. playlist: this.main.playlists[id],
  409. status: xhr.status,
  410. message: `HLS playlist request error at URL: ${uri}.`,
  411. responseText: xhr.responseText,
  412. code: (xhr.status >= 500) ? 4 : 2
  413. };
  414. this.trigger('error');
  415. }
  416. parseManifest_({url, manifestString}) {
  417. return parseManifest({
  418. onwarn: ({message}) => this.logger_(`m3u8-parser warn for ${url}: ${message}`),
  419. oninfo: ({message}) => this.logger_(`m3u8-parser info for ${url}: ${message}`),
  420. manifestString,
  421. customTagParsers: this.customTagParsers,
  422. customTagMappers: this.customTagMappers,
  423. llhls: this.llhls
  424. });
  425. }
  426. /**
  427. * Update the playlist loader's state in response to a new or updated playlist.
  428. *
  429. * @param {string} [playlistString]
  430. * Playlist string (if playlistObject is not provided)
  431. * @param {Object} [playlistObject]
  432. * Playlist object (if playlistString is not provided)
  433. * @param {string} url
  434. * URL of playlist
  435. * @param {string} id
  436. * ID to use for playlist
  437. */
  438. haveMetadata({ playlistString, playlistObject, url, id }) {
  439. // any in-flight request is now finished
  440. this.request = null;
  441. this.state = 'HAVE_METADATA';
  442. const playlist = playlistObject || this.parseManifest_({
  443. url,
  444. manifestString: playlistString
  445. });
  446. playlist.lastRequest = Date.now();
  447. setupMediaPlaylist({
  448. playlist,
  449. uri: url,
  450. id
  451. });
  452. // merge this playlist into the main manifest
  453. const update = updateMain(this.main, playlist);
  454. this.targetDuration = playlist.partTargetDuration || playlist.targetDuration;
  455. this.pendingMedia_ = null;
  456. if (update) {
  457. this.main = update;
  458. this.media_ = this.main.playlists[id];
  459. } else {
  460. this.trigger('playlistunchanged');
  461. }
  462. this.updateMediaUpdateTimeout_(refreshDelay(this.media(), !!update));
  463. this.trigger('loadedplaylist');
  464. }
  465. /**
  466. * Abort any outstanding work and clean up.
  467. */
  468. dispose() {
  469. this.trigger('dispose');
  470. this.stopRequest();
  471. window.clearTimeout(this.mediaUpdateTimeout);
  472. window.clearTimeout(this.finalRenditionTimeout);
  473. this.dateRangesStorage_ = new DateRangesStorage();
  474. this.off();
  475. }
  476. stopRequest() {
  477. if (this.request) {
  478. const oldRequest = this.request;
  479. this.request = null;
  480. oldRequest.onreadystatechange = null;
  481. oldRequest.abort();
  482. }
  483. }
  484. /**
  485. * When called without any arguments, returns the currently
  486. * active media playlist. When called with a single argument,
  487. * triggers the playlist loader to asynchronously switch to the
  488. * specified media playlist. Calling this method while the
  489. * loader is in the HAVE_NOTHING causes an error to be emitted
  490. * but otherwise has no effect.
  491. *
  492. * @param {Object=} playlist the parsed media playlist
  493. * object to switch to
  494. * @param {boolean=} shouldDelay whether we should delay the request by half target duration
  495. *
  496. * @return {Playlist} the current loaded media
  497. */
  498. media(playlist, shouldDelay) {
  499. // getter
  500. if (!playlist) {
  501. return this.media_;
  502. }
  503. // setter
  504. if (this.state === 'HAVE_NOTHING') {
  505. throw new Error('Cannot switch media playlist from ' + this.state);
  506. }
  507. // find the playlist object if the target playlist has been
  508. // specified by URI
  509. if (typeof playlist === 'string') {
  510. if (!this.main.playlists[playlist]) {
  511. throw new Error('Unknown playlist URI: ' + playlist);
  512. }
  513. playlist = this.main.playlists[playlist];
  514. }
  515. window.clearTimeout(this.finalRenditionTimeout);
  516. if (shouldDelay) {
  517. const delay = ((playlist.partTargetDuration || playlist.targetDuration) / 2) * 1000 || 5 * 1000;
  518. this.finalRenditionTimeout =
  519. window.setTimeout(this.media.bind(this, playlist, false), delay);
  520. return;
  521. }
  522. const startingState = this.state;
  523. const mediaChange = !this.media_ || playlist.id !== this.media_.id;
  524. const mainPlaylistRef = this.main.playlists[playlist.id];
  525. // switch to fully loaded playlists immediately
  526. if (mainPlaylistRef && mainPlaylistRef.endList ||
  527. // handle the case of a playlist object (e.g., if using vhs-json with a resolved
  528. // media playlist or, for the case of demuxed audio, a resolved audio media group)
  529. (playlist.endList && playlist.segments.length)) {
  530. // abort outstanding playlist requests
  531. if (this.request) {
  532. this.request.onreadystatechange = null;
  533. this.request.abort();
  534. this.request = null;
  535. }
  536. this.state = 'HAVE_METADATA';
  537. this.media_ = playlist;
  538. // trigger media change if the active media has been updated
  539. if (mediaChange) {
  540. this.trigger('mediachanging');
  541. if (startingState === 'HAVE_MAIN_MANIFEST') {
  542. // The initial playlist was a main manifest, and the first media selected was
  543. // also provided (in the form of a resolved playlist object) as part of the
  544. // source object (rather than just a URL). Therefore, since the media playlist
  545. // doesn't need to be requested, loadedmetadata won't trigger as part of the
  546. // normal flow, and needs an explicit trigger here.
  547. this.trigger('loadedmetadata');
  548. } else {
  549. this.trigger('mediachange');
  550. }
  551. }
  552. return;
  553. }
  554. // We update/set the timeout here so that live playlists
  555. // that are not a media change will "start" the loader as expected.
  556. // We expect that this function will start the media update timeout
  557. // cycle again. This also prevents a playlist switch failure from
  558. // causing us to stall during live.
  559. this.updateMediaUpdateTimeout_(refreshDelay(playlist, true));
  560. // switching to the active playlist is a no-op
  561. if (!mediaChange) {
  562. return;
  563. }
  564. this.state = 'SWITCHING_MEDIA';
  565. // there is already an outstanding playlist request
  566. if (this.request) {
  567. if (playlist.resolvedUri === this.request.url) {
  568. // requesting to switch to the same playlist multiple times
  569. // has no effect after the first
  570. return;
  571. }
  572. this.request.onreadystatechange = null;
  573. this.request.abort();
  574. this.request = null;
  575. }
  576. // request the new playlist
  577. if (this.media_) {
  578. this.trigger('mediachanging');
  579. }
  580. this.pendingMedia_ = playlist;
  581. this.request = this.vhs_.xhr({
  582. uri: playlist.resolvedUri,
  583. withCredentials: this.withCredentials
  584. }, (error, req) => {
  585. // disposed
  586. if (!this.request) {
  587. return;
  588. }
  589. playlist.lastRequest = Date.now();
  590. playlist.resolvedUri = resolveManifestRedirect(playlist.resolvedUri, req);
  591. if (error) {
  592. return this.playlistRequestError(this.request, playlist, startingState);
  593. }
  594. this.haveMetadata({
  595. playlistString: req.responseText,
  596. url: playlist.uri,
  597. id: playlist.id
  598. });
  599. // fire loadedmetadata the first time a media playlist is loaded
  600. if (startingState === 'HAVE_MAIN_MANIFEST') {
  601. this.trigger('loadedmetadata');
  602. } else {
  603. this.trigger('mediachange');
  604. }
  605. });
  606. }
  607. /**
  608. * pause loading of the playlist
  609. */
  610. pause() {
  611. if (this.mediaUpdateTimeout) {
  612. window.clearTimeout(this.mediaUpdateTimeout);
  613. this.mediaUpdateTimeout = null;
  614. }
  615. this.stopRequest();
  616. if (this.state === 'HAVE_NOTHING') {
  617. // If we pause the loader before any data has been retrieved, its as if we never
  618. // started, so reset to an unstarted state.
  619. this.started = false;
  620. }
  621. // Need to restore state now that no activity is happening
  622. if (this.state === 'SWITCHING_MEDIA') {
  623. // if the loader was in the process of switching media, it should either return to
  624. // HAVE_MAIN_MANIFEST or HAVE_METADATA depending on if the loader has loaded a media
  625. // playlist yet. This is determined by the existence of loader.media_
  626. if (this.media_) {
  627. this.state = 'HAVE_METADATA';
  628. } else {
  629. this.state = 'HAVE_MAIN_MANIFEST';
  630. }
  631. } else if (this.state === 'HAVE_CURRENT_METADATA') {
  632. this.state = 'HAVE_METADATA';
  633. }
  634. }
  635. /**
  636. * start loading of the playlist
  637. */
  638. load(shouldDelay) {
  639. if (this.mediaUpdateTimeout) {
  640. window.clearTimeout(this.mediaUpdateTimeout);
  641. this.mediaUpdateTimeout = null;
  642. }
  643. const media = this.media();
  644. if (shouldDelay) {
  645. const delay = media ? ((media.partTargetDuration || media.targetDuration) / 2) * 1000 : 5 * 1000;
  646. this.mediaUpdateTimeout = window.setTimeout(() => {
  647. this.mediaUpdateTimeout = null;
  648. this.load();
  649. }, delay);
  650. return;
  651. }
  652. if (!this.started) {
  653. this.start();
  654. return;
  655. }
  656. if (media && !media.endList) {
  657. this.trigger('mediaupdatetimeout');
  658. } else {
  659. this.trigger('loadedplaylist');
  660. }
  661. }
  662. updateMediaUpdateTimeout_(delay) {
  663. if (this.mediaUpdateTimeout) {
  664. window.clearTimeout(this.mediaUpdateTimeout);
  665. this.mediaUpdateTimeout = null;
  666. }
  667. // we only have use mediaupdatetimeout for live playlists.
  668. if (!this.media() || this.media().endList) {
  669. return;
  670. }
  671. this.mediaUpdateTimeout = window.setTimeout(() => {
  672. this.mediaUpdateTimeout = null;
  673. this.trigger('mediaupdatetimeout');
  674. this.updateMediaUpdateTimeout_(delay);
  675. }, delay);
  676. }
  677. /**
  678. * start loading of the playlist
  679. */
  680. start() {
  681. this.started = true;
  682. if (typeof this.src === 'object') {
  683. // in the case of an entirely constructed manifest object (meaning there's no actual
  684. // manifest on a server), default the uri to the page's href
  685. if (!this.src.uri) {
  686. this.src.uri = window.location.href;
  687. }
  688. // resolvedUri is added on internally after the initial request. Since there's no
  689. // request for pre-resolved manifests, add on resolvedUri here.
  690. this.src.resolvedUri = this.src.uri;
  691. // Since a manifest object was passed in as the source (instead of a URL), the first
  692. // request can be skipped (since the top level of the manifest, at a minimum, is
  693. // already available as a parsed manifest object). However, if the manifest object
  694. // represents a main playlist, some media playlists may need to be resolved before
  695. // the starting segment list is available. Therefore, go directly to setup of the
  696. // initial playlist, and let the normal flow continue from there.
  697. //
  698. // Note that the call to setup is asynchronous, as other sections of VHS may assume
  699. // that the first request is asynchronous.
  700. setTimeout(() => {
  701. this.setupInitialPlaylist(this.src);
  702. }, 0);
  703. return;
  704. }
  705. // request the specified URL
  706. this.request = this.vhs_.xhr({
  707. uri: this.src,
  708. withCredentials: this.withCredentials
  709. }, (error, req) => {
  710. // disposed
  711. if (!this.request) {
  712. return;
  713. }
  714. // clear the loader's request reference
  715. this.request = null;
  716. if (error) {
  717. this.error = {
  718. status: req.status,
  719. message: `HLS playlist request error at URL: ${this.src}.`,
  720. responseText: req.responseText,
  721. // MEDIA_ERR_NETWORK
  722. code: 2
  723. };
  724. if (this.state === 'HAVE_NOTHING') {
  725. this.started = false;
  726. }
  727. return this.trigger('error');
  728. }
  729. this.src = resolveManifestRedirect(this.src, req);
  730. const manifest = this.parseManifest_({
  731. manifestString: req.responseText,
  732. url: this.src
  733. });
  734. this.setupInitialPlaylist(manifest);
  735. });
  736. }
  737. srcUri() {
  738. return typeof this.src === 'string' ? this.src : this.src.uri;
  739. }
  740. /**
  741. * Given a manifest object that's either a main or media playlist, trigger the proper
  742. * events and set the state of the playlist loader.
  743. *
  744. * If the manifest object represents a main playlist, `loadedplaylist` will be
  745. * triggered to allow listeners to select a playlist. If none is selected, the loader
  746. * will default to the first one in the playlists array.
  747. *
  748. * If the manifest object represents a media playlist, `loadedplaylist` will be
  749. * triggered followed by `loadedmetadata`, as the only available playlist is loaded.
  750. *
  751. * In the case of a media playlist, a main playlist object wrapper with one playlist
  752. * will be created so that all logic can handle playlists in the same fashion (as an
  753. * assumed manifest object schema).
  754. *
  755. * @param {Object} manifest
  756. * The parsed manifest object
  757. */
  758. setupInitialPlaylist(manifest) {
  759. this.state = 'HAVE_MAIN_MANIFEST';
  760. if (manifest.playlists) {
  761. this.main = manifest;
  762. addPropertiesToMain(this.main, this.srcUri());
  763. // If the initial main playlist has playlists wtih segments already resolved,
  764. // then resolve URIs in advance, as they are usually done after a playlist request,
  765. // which may not happen if the playlist is resolved.
  766. manifest.playlists.forEach((playlist) => {
  767. playlist.segments = getAllSegments(playlist);
  768. playlist.segments.forEach((segment) => {
  769. resolveSegmentUris(segment, playlist.resolvedUri);
  770. });
  771. });
  772. this.trigger('loadedplaylist');
  773. if (!this.request) {
  774. // no media playlist was specifically selected so start
  775. // from the first listed one
  776. this.media(this.main.playlists[0]);
  777. }
  778. return;
  779. }
  780. // In order to support media playlists passed in as vhs-json, the case where the uri
  781. // is not provided as part of the manifest should be considered, and an appropriate
  782. // default used.
  783. const uri = this.srcUri() || window.location.href;
  784. this.main = mainForMedia(manifest, uri);
  785. this.haveMetadata({
  786. playlistObject: manifest,
  787. url: uri,
  788. id: this.main.playlists[0].id
  789. });
  790. this.trigger('loadedmetadata');
  791. }
  792. /**
  793. * Updates or deletes a preexisting pathway clone.
  794. * Ensures that all playlists related to the old pathway clone are
  795. * either updated or deleted.
  796. *
  797. * @param {Object} clone On update, the pathway clone object for the newly updated pathway clone.
  798. * On delete, the old pathway clone object to be deleted.
  799. * @param {boolean} isUpdate True if the pathway is to be updated,
  800. * false if it is meant to be deleted.
  801. */
  802. updateOrDeleteClone(clone, isUpdate) {
  803. const main = this.main;
  804. const pathway = clone.ID;
  805. let i = main.playlists.length;
  806. // Iterate backwards through the playlist so we can remove playlists if necessary.
  807. while (i--) {
  808. const p = main.playlists[i];
  809. if (p.attributes['PATHWAY-ID'] === pathway) {
  810. const oldPlaylistUri = p.resolvedUri;
  811. const oldPlaylistId = p.id;
  812. // update the indexed playlist and add new playlists by ID and URI
  813. if (isUpdate) {
  814. const newPlaylistUri = this.createCloneURI_(p.resolvedUri, clone);
  815. const newPlaylistId = createPlaylistID(pathway, newPlaylistUri);
  816. const attributes = this.createCloneAttributes_(pathway, p.attributes);
  817. const updatedPlaylist = this.createClonePlaylist_(p, newPlaylistId, clone, attributes);
  818. main.playlists[i] = updatedPlaylist;
  819. main.playlists[newPlaylistId] = updatedPlaylist;
  820. main.playlists[newPlaylistUri] = updatedPlaylist;
  821. } else {
  822. // Remove the indexed playlist.
  823. main.playlists.splice(i, 1);
  824. }
  825. // Remove playlists by the old ID and URI.
  826. delete main.playlists[oldPlaylistId];
  827. delete main.playlists[oldPlaylistUri];
  828. }
  829. }
  830. this.updateOrDeleteCloneMedia(clone, isUpdate);
  831. }
  832. /**
  833. * Updates or deletes media data based on the pathway clone object.
  834. * Due to the complexity of the media groups and playlists, in all cases
  835. * we remove all of the old media groups and playlists.
  836. * On updates, we then create new media groups and playlists based on the
  837. * new pathway clone object.
  838. *
  839. * @param {Object} clone The pathway clone object for the newly updated pathway clone.
  840. * @param {boolean} isUpdate True if the pathway is to be updated,
  841. * false if it is meant to be deleted.
  842. */
  843. updateOrDeleteCloneMedia(clone, isUpdate) {
  844. const main = this.main;
  845. const id = clone.ID;
  846. ['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((mediaType) => {
  847. if (!main.mediaGroups[mediaType] || !main.mediaGroups[mediaType][id]) {
  848. return;
  849. }
  850. for (const groupKey in main.mediaGroups[mediaType]) {
  851. // Remove all media playlists for the media group for this pathway clone.
  852. if (groupKey === id) {
  853. for (const labelKey in main.mediaGroups[mediaType][groupKey]) {
  854. const oldMedia = main.mediaGroups[mediaType][groupKey][labelKey];
  855. oldMedia.playlists.forEach((p, i) => {
  856. const oldMediaPlaylist = main.playlists[p.id];
  857. const oldPlaylistId = oldMediaPlaylist.id;
  858. const oldPlaylistUri = oldMediaPlaylist.resolvedUri;
  859. delete main.playlists[oldPlaylistId];
  860. delete main.playlists[oldPlaylistUri];
  861. });
  862. }
  863. // Delete the old media group.
  864. delete main.mediaGroups[mediaType][groupKey];
  865. }
  866. }
  867. });
  868. // Create the new media groups and playlists if there is an update.
  869. if (isUpdate) {
  870. this.createClonedMediaGroups_(clone);
  871. }
  872. }
  873. /**
  874. * Given a pathway clone object, clones all necessary playlists.
  875. *
  876. * @param {Object} clone The pathway clone object.
  877. * @param {Object} basePlaylist The original playlist to clone from.
  878. */
  879. addClonePathway(clone, basePlaylist = {}) {
  880. const main = this.main;
  881. const index = main.playlists.length;
  882. const uri = this.createCloneURI_(basePlaylist.resolvedUri, clone);
  883. const playlistId = createPlaylistID(clone.ID, uri);
  884. const attributes = this.createCloneAttributes_(clone.ID, basePlaylist.attributes);
  885. const playlist = this.createClonePlaylist_(basePlaylist, playlistId, clone, attributes);
  886. main.playlists[index] = playlist;
  887. // add playlist by ID and URI
  888. main.playlists[playlistId] = playlist;
  889. main.playlists[uri] = playlist;
  890. this.createClonedMediaGroups_(clone);
  891. }
  892. /**
  893. * Given a pathway clone object we create clones of all media.
  894. * In this function, all necessary information and updated playlists
  895. * are added to the `mediaGroup` object.
  896. * Playlists are also added to the `playlists` array so the media groups
  897. * will be properly linked.
  898. *
  899. * @param {Object} clone The pathway clone object.
  900. */
  901. createClonedMediaGroups_(clone) {
  902. const id = clone.ID;
  903. const baseID = clone['BASE-ID'];
  904. const main = this.main;
  905. ['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((mediaType) => {
  906. // If the media type doesn't exist, or there is already a clone, skip
  907. // to the next media type.
  908. if (!main.mediaGroups[mediaType] || main.mediaGroups[mediaType][id]) {
  909. return;
  910. }
  911. for (const groupKey in main.mediaGroups[mediaType]) {
  912. if (groupKey === baseID) {
  913. // Create the group.
  914. main.mediaGroups[mediaType][id] = {};
  915. } else {
  916. // There is no need to iterate over label keys in this case.
  917. continue;
  918. }
  919. for (const labelKey in main.mediaGroups[mediaType][groupKey]) {
  920. const oldMedia = main.mediaGroups[mediaType][groupKey][labelKey];
  921. main.mediaGroups[mediaType][id][labelKey] = Object.assign({}, oldMedia);
  922. const newMedia = main.mediaGroups[mediaType][id][labelKey];
  923. // update URIs on the media
  924. const newUri = this.createCloneURI_(oldMedia.resolvedUri, clone);
  925. newMedia.resolvedUri = newUri;
  926. newMedia.uri = newUri;
  927. // Reset playlists in the new media group.
  928. newMedia.playlists = [];
  929. // Create new playlists in the newly cloned media group.
  930. oldMedia.playlists.forEach((p, i) => {
  931. const oldMediaPlaylist = main.playlists[p.id];
  932. const group = groupID(mediaType, id, labelKey);
  933. const newPlaylistID = createPlaylistID(id, group);
  934. // Check to see if it already exists
  935. if (oldMediaPlaylist && !main.playlists[newPlaylistID]) {
  936. const newMediaPlaylist = this.createClonePlaylist_(oldMediaPlaylist, newPlaylistID, clone);
  937. const newPlaylistUri = newMediaPlaylist.resolvedUri;
  938. main.playlists[newPlaylistID] = newMediaPlaylist;
  939. main.playlists[newPlaylistUri] = newMediaPlaylist;
  940. }
  941. newMedia.playlists[i] = this.createClonePlaylist_(p, newPlaylistID, clone);
  942. });
  943. }
  944. }
  945. });
  946. }
  947. /**
  948. * Using the original playlist to be cloned, and the pathway clone object
  949. * information, we create a new playlist.
  950. *
  951. * @param {Object} basePlaylist The original playlist to be cloned from.
  952. * @param {string} id The desired id of the newly cloned playlist.
  953. * @param {Object} clone The pathway clone object.
  954. * @param {Object} attributes An optional object to populate the `attributes` property in the playlist.
  955. *
  956. * @return {Object} The combined cloned playlist.
  957. */
  958. createClonePlaylist_(basePlaylist, id, clone, attributes) {
  959. const uri = this.createCloneURI_(basePlaylist.resolvedUri, clone);
  960. const newProps = {
  961. resolvedUri: uri,
  962. uri,
  963. id
  964. };
  965. // Remove all segments from previous playlist in the clone.
  966. if (basePlaylist.segments) {
  967. newProps.segments = [];
  968. }
  969. if (attributes) {
  970. newProps.attributes = attributes;
  971. }
  972. return merge(basePlaylist, newProps);
  973. }
  974. /**
  975. * Generates an updated URI for a cloned pathway based on the original
  976. * pathway's URI and the paramaters from the pathway clone object in the
  977. * content steering server response.
  978. *
  979. * @param {string} baseUri URI to be updated in the cloned pathway.
  980. * @param {Object} clone The pathway clone object.
  981. *
  982. * @return {string} The updated URI for the cloned pathway.
  983. */
  984. createCloneURI_(baseURI, clone) {
  985. const uri = new URL(baseURI);
  986. uri.hostname = clone['URI-REPLACEMENT'].HOST;
  987. const params = clone['URI-REPLACEMENT'].PARAMS;
  988. // Add params to the cloned URL.
  989. for (const key of Object.keys(params)) {
  990. uri.searchParams.set(key, params[key]);
  991. }
  992. return uri.href;
  993. }
  994. /**
  995. * Helper function to create the attributes needed for the new clone.
  996. * This mainly adds the necessary media attributes.
  997. *
  998. * @param {string} id The pathway clone object ID.
  999. * @param {Object} oldAttributes The old attributes to compare to.
  1000. * @return {Object} The new attributes to add to the playlist.
  1001. */
  1002. createCloneAttributes_(id, oldAttributes) {
  1003. const attributes = { ['PATHWAY-ID']: id };
  1004. ['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((mediaType) => {
  1005. if (oldAttributes[mediaType]) {
  1006. attributes[mediaType] = id;
  1007. }
  1008. });
  1009. return attributes;
  1010. }
  1011. /**
  1012. * Returns the key ID set from a playlist
  1013. *
  1014. * @param {playlist} playlist to fetch the key ID set from.
  1015. * @return a Set of 32 digit hex strings that represent the unique keyIds for that playlist.
  1016. */
  1017. getKeyIdSet(playlist) {
  1018. if (playlist.contentProtection) {
  1019. const keyIds = new Set();
  1020. for (const keysystem in playlist.contentProtection) {
  1021. const keyId = playlist.contentProtection[keysystem].attributes.keyId;
  1022. if (keyId) {
  1023. keyIds.add(keyId.toLowerCase());
  1024. }
  1025. }
  1026. return keyIds;
  1027. }
  1028. }
  1029. }