Home Reference Source

src/loader/playlist-loader.ts

  1. /**
  2. * PlaylistLoader - delegate for media manifest/playlist loading tasks. Takes care of parsing media to internal data-models.
  3. *
  4. * Once loaded, dispatches events with parsed data-models of manifest/levels/audio/subtitle tracks.
  5. *
  6. * Uses loader(s) set in config to do actual internal loading of resource tasks.
  7. *
  8. * @module
  9. *
  10. */
  11.  
  12. import { Events } from '../events';
  13. import { ErrorDetails, ErrorTypes } from '../errors';
  14. import { logger } from '../utils/logger';
  15. import { parseSegmentIndex, findBox } from '../utils/mp4-tools';
  16. import M3U8Parser from './m3u8-parser';
  17. import type { LevelParsed } from '../types/level';
  18. import type {
  19. Loader,
  20. LoaderConfiguration,
  21. LoaderContext,
  22. LoaderResponse,
  23. LoaderStats,
  24. PlaylistLoaderContext,
  25. } from '../types/loader';
  26. import { PlaylistContextType, PlaylistLevelType } from '../types/loader';
  27. import { LevelDetails } from './level-details';
  28. import type Hls from '../hls';
  29. import { AttrList } from '../utils/attr-list';
  30. import type {
  31. ErrorData,
  32. LevelLoadingData,
  33. ManifestLoadingData,
  34. TrackLoadingData,
  35. } from '../types/events';
  36. import { NetworkComponentAPI } from '../types/component-api';
  37.  
  38. function mapContextToLevelType(
  39. context: PlaylistLoaderContext
  40. ): PlaylistLevelType {
  41. const { type } = context;
  42.  
  43. switch (type) {
  44. case PlaylistContextType.AUDIO_TRACK:
  45. return PlaylistLevelType.AUDIO;
  46. case PlaylistContextType.SUBTITLE_TRACK:
  47. return PlaylistLevelType.SUBTITLE;
  48. default:
  49. return PlaylistLevelType.MAIN;
  50. }
  51. }
  52.  
  53. function getResponseUrl(
  54. response: LoaderResponse,
  55. context: PlaylistLoaderContext
  56. ): string {
  57. let url = response.url;
  58. // responseURL not supported on some browsers (it is used to detect URL redirection)
  59. // data-uri mode also not supported (but no need to detect redirection)
  60. if (url === undefined || url.indexOf('data:') === 0) {
  61. // fallback to initial URL
  62. url = context.url;
  63. }
  64. return url;
  65. }
  66.  
  67. class PlaylistLoader implements NetworkComponentAPI {
  68. private readonly hls: Hls;
  69. private readonly loaders: {
  70. [key: string]: Loader<LoaderContext>;
  71. } = Object.create(null);
  72.  
  73. constructor(hls: Hls) {
  74. this.hls = hls;
  75. this.registerListeners();
  76. }
  77.  
  78. public startLoad(startPosition: number): void {}
  79.  
  80. public stopLoad(): void {
  81. this.destroyInternalLoaders();
  82. }
  83.  
  84. private registerListeners() {
  85. const { hls } = this;
  86. hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
  87. hls.on(Events.LEVEL_LOADING, this.onLevelLoading, this);
  88. hls.on(Events.AUDIO_TRACK_LOADING, this.onAudioTrackLoading, this);
  89. hls.on(Events.SUBTITLE_TRACK_LOADING, this.onSubtitleTrackLoading, this);
  90. }
  91.  
  92. private unregisterListeners() {
  93. const { hls } = this;
  94. hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
  95. hls.off(Events.LEVEL_LOADING, this.onLevelLoading, this);
  96. hls.off(Events.AUDIO_TRACK_LOADING, this.onAudioTrackLoading, this);
  97. hls.off(Events.SUBTITLE_TRACK_LOADING, this.onSubtitleTrackLoading, this);
  98. }
  99.  
  100. /**
  101. * Returns defaults or configured loader-type overloads (pLoader and loader config params)
  102. */
  103. private createInternalLoader(
  104. context: PlaylistLoaderContext
  105. ): Loader<LoaderContext> {
  106. const config = this.hls.config;
  107. const PLoader = config.pLoader;
  108. const Loader = config.loader;
  109. const InternalLoader = PLoader || Loader;
  110.  
  111. const loader = new InternalLoader(config) as Loader<PlaylistLoaderContext>;
  112.  
  113. context.loader = loader;
  114. this.loaders[context.type] = loader;
  115.  
  116. return loader;
  117. }
  118.  
  119. private getInternalLoader(
  120. context: PlaylistLoaderContext
  121. ): Loader<LoaderContext> {
  122. return this.loaders[context.type];
  123. }
  124.  
  125. private resetInternalLoader(contextType): void {
  126. if (this.loaders[contextType]) {
  127. delete this.loaders[contextType];
  128. }
  129. }
  130.  
  131. /**
  132. * Call `destroy` on all internal loader instances mapped (one per context type)
  133. */
  134. private destroyInternalLoaders(): void {
  135. for (const contextType in this.loaders) {
  136. const loader = this.loaders[contextType];
  137. if (loader) {
  138. loader.destroy();
  139. }
  140.  
  141. this.resetInternalLoader(contextType);
  142. }
  143. }
  144.  
  145. public destroy(): void {
  146. this.unregisterListeners();
  147. this.destroyInternalLoaders();
  148. }
  149.  
  150. private onManifestLoading(
  151. event: Events.MANIFEST_LOADING,
  152. data: ManifestLoadingData
  153. ) {
  154. const { url } = data;
  155. this.load({
  156. id: null,
  157. groupId: null,
  158. level: 0,
  159. responseType: 'text',
  160. type: PlaylistContextType.MANIFEST,
  161. url,
  162. deliveryDirectives: null,
  163. });
  164. }
  165.  
  166. private onLevelLoading(event: Events.LEVEL_LOADING, data: LevelLoadingData) {
  167. const { id, level, url, deliveryDirectives } = data;
  168. this.load({
  169. id,
  170. groupId: null,
  171. level,
  172. responseType: 'text',
  173. type: PlaylistContextType.LEVEL,
  174. url,
  175. deliveryDirectives,
  176. });
  177. }
  178.  
  179. private onAudioTrackLoading(
  180. event: Events.AUDIO_TRACK_LOADING,
  181. data: TrackLoadingData
  182. ) {
  183. const { id, groupId, url, deliveryDirectives } = data;
  184. this.load({
  185. id,
  186. groupId,
  187. level: null,
  188. responseType: 'text',
  189. type: PlaylistContextType.AUDIO_TRACK,
  190. url,
  191. deliveryDirectives,
  192. });
  193. }
  194.  
  195. private onSubtitleTrackLoading(
  196. event: Events.SUBTITLE_TRACK_LOADING,
  197. data: TrackLoadingData
  198. ) {
  199. const { id, groupId, url, deliveryDirectives } = data;
  200. this.load({
  201. id,
  202. groupId,
  203. level: null,
  204. responseType: 'text',
  205. type: PlaylistContextType.SUBTITLE_TRACK,
  206. url,
  207. deliveryDirectives,
  208. });
  209. }
  210.  
  211. private load(context: PlaylistLoaderContext): void {
  212. const config = this.hls.config;
  213.  
  214. // logger.debug(`[playlist-loader]: Loading playlist of type ${context.type}, level: ${context.level}, id: ${context.id}`);
  215.  
  216. // Check if a loader for this context already exists
  217. let loader = this.getInternalLoader(context);
  218. if (loader) {
  219. const loaderContext = loader.context;
  220. if (loaderContext && loaderContext.url === context.url) {
  221. // same URL can't overlap
  222. logger.trace('[playlist-loader]: playlist request ongoing');
  223. return;
  224. }
  225. logger.log(
  226. `[playlist-loader]: aborting previous loader for type: ${context.type}`
  227. );
  228. loader.abort();
  229. }
  230.  
  231. let maxRetry;
  232. let timeout;
  233. let retryDelay;
  234. let maxRetryDelay;
  235.  
  236. // apply different configs for retries depending on
  237. // context (manifest, level, audio/subs playlist)
  238. switch (context.type) {
  239. case PlaylistContextType.MANIFEST:
  240. maxRetry = config.manifestLoadingMaxRetry;
  241. timeout = config.manifestLoadingTimeOut;
  242. retryDelay = config.manifestLoadingRetryDelay;
  243. maxRetryDelay = config.manifestLoadingMaxRetryTimeout;
  244. break;
  245. case PlaylistContextType.LEVEL:
  246. case PlaylistContextType.AUDIO_TRACK:
  247. case PlaylistContextType.SUBTITLE_TRACK:
  248. // Manage retries in Level/Track Controller
  249. maxRetry = 0;
  250. timeout = config.levelLoadingTimeOut;
  251. break;
  252. default:
  253. maxRetry = config.levelLoadingMaxRetry;
  254. timeout = config.levelLoadingTimeOut;
  255. retryDelay = config.levelLoadingRetryDelay;
  256. maxRetryDelay = config.levelLoadingMaxRetryTimeout;
  257. break;
  258. }
  259.  
  260. loader = this.createInternalLoader(context);
  261.  
  262. // Override level/track timeout for LL-HLS requests
  263. // (the default of 10000ms is counter productive to blocking playlist reload requests)
  264. if (context.deliveryDirectives?.part) {
  265. let levelDetails: LevelDetails | undefined;
  266. if (
  267. context.type === PlaylistContextType.LEVEL &&
  268. context.level !== null
  269. ) {
  270. levelDetails = this.hls.levels[context.level].details;
  271. } else if (
  272. context.type === PlaylistContextType.AUDIO_TRACK &&
  273. context.id !== null
  274. ) {
  275. levelDetails = this.hls.audioTracks[context.id].details;
  276. } else if (
  277. context.type === PlaylistContextType.SUBTITLE_TRACK &&
  278. context.id !== null
  279. ) {
  280. levelDetails = this.hls.subtitleTracks[context.id].details;
  281. }
  282. if (levelDetails) {
  283. const partTarget = levelDetails.partTarget;
  284. const targetDuration = levelDetails.targetduration;
  285. if (partTarget && targetDuration) {
  286. timeout = Math.min(
  287. Math.max(partTarget * 3, targetDuration * 0.8) * 1000,
  288. timeout
  289. );
  290. }
  291. }
  292. }
  293.  
  294. const loaderConfig: LoaderConfiguration = {
  295. timeout,
  296. maxRetry,
  297. retryDelay,
  298. maxRetryDelay,
  299. highWaterMark: 0,
  300. };
  301.  
  302. const loaderCallbacks = {
  303. onSuccess: this.loadsuccess.bind(this),
  304. onError: this.loaderror.bind(this),
  305. onTimeout: this.loadtimeout.bind(this),
  306. };
  307.  
  308. // logger.debug(`[playlist-loader]: Calling internal loader delegate for URL: ${context.url}`);
  309.  
  310. loader.load(context, loaderConfig, loaderCallbacks);
  311. }
  312.  
  313. private loadsuccess(
  314. response: LoaderResponse,
  315. stats: LoaderStats,
  316. context: PlaylistLoaderContext,
  317. networkDetails: any = null
  318. ): void {
  319. if (context.isSidxRequest) {
  320. this.handleSidxRequest(response, context);
  321. this.handlePlaylistLoaded(response, stats, context, networkDetails);
  322. return;
  323. }
  324.  
  325. this.resetInternalLoader(context.type);
  326.  
  327. const string = response.data as string;
  328.  
  329. // Validate if it is an M3U8 at all
  330. if (string.indexOf('#EXTM3U') !== 0) {
  331. this.handleManifestParsingError(
  332. response,
  333. context,
  334. 'no EXTM3U delimiter',
  335. networkDetails
  336. );
  337. return;
  338. }
  339.  
  340. stats.parsing.start = performance.now();
  341. // Check if chunk-list or master. handle empty chunk list case (first EXTINF not signaled, but TARGETDURATION present)
  342. if (
  343. string.indexOf('#EXTINF:') > 0 ||
  344. string.indexOf('#EXT-X-TARGETDURATION:') > 0
  345. ) {
  346. this.handleTrackOrLevelPlaylist(response, stats, context, networkDetails);
  347. } else {
  348. this.handleMasterPlaylist(response, stats, context, networkDetails);
  349. }
  350. }
  351.  
  352. private loaderror(
  353. response: LoaderResponse,
  354. context: PlaylistLoaderContext,
  355. networkDetails: any = null
  356. ): void {
  357. this.handleNetworkError(context, networkDetails, false, response);
  358. }
  359.  
  360. private loadtimeout(
  361. stats: LoaderStats,
  362. context: PlaylistLoaderContext,
  363. networkDetails: any = null
  364. ): void {
  365. this.handleNetworkError(context, networkDetails, true);
  366. }
  367.  
  368. private handleMasterPlaylist(
  369. response: LoaderResponse,
  370. stats: LoaderStats,
  371. context: PlaylistLoaderContext,
  372. networkDetails: any
  373. ): void {
  374. const hls = this.hls;
  375. const string = response.data as string;
  376.  
  377. const url = getResponseUrl(response, context);
  378.  
  379. const { levels, sessionData, sessionKeys } = M3U8Parser.parseMasterPlaylist(
  380. string,
  381. url
  382. );
  383. if (!levels.length) {
  384. this.handleManifestParsingError(
  385. response,
  386. context,
  387. 'no level found in manifest',
  388. networkDetails
  389. );
  390. return;
  391. }
  392.  
  393. // multi level playlist, parse level info
  394. const audioGroups = levels.map((level: LevelParsed) => ({
  395. id: level.attrs.AUDIO,
  396. audioCodec: level.audioCodec,
  397. }));
  398.  
  399. const subtitleGroups = levels.map((level: LevelParsed) => ({
  400. id: level.attrs.SUBTITLES,
  401. textCodec: level.textCodec,
  402. }));
  403.  
  404. const audioTracks = M3U8Parser.parseMasterPlaylistMedia(
  405. string,
  406. url,
  407. 'AUDIO',
  408. audioGroups
  409. );
  410. const subtitles = M3U8Parser.parseMasterPlaylistMedia(
  411. string,
  412. url,
  413. 'SUBTITLES',
  414. subtitleGroups
  415. );
  416. const captions = M3U8Parser.parseMasterPlaylistMedia(
  417. string,
  418. url,
  419. 'CLOSED-CAPTIONS'
  420. );
  421.  
  422. if (audioTracks.length) {
  423. // check if we have found an audio track embedded in main playlist (audio track without URI attribute)
  424. const embeddedAudioFound: boolean = audioTracks.some(
  425. (audioTrack) => !audioTrack.url
  426. );
  427.  
  428. // if no embedded audio track defined, but audio codec signaled in quality level,
  429. // we need to signal this main audio track this could happen with playlists with
  430. // alt audio rendition in which quality levels (main)
  431. // contains both audio+video. but with mixed audio track not signaled
  432. if (
  433. !embeddedAudioFound &&
  434. levels[0].audioCodec &&
  435. !levels[0].attrs.AUDIO
  436. ) {
  437. logger.log(
  438. '[playlist-loader]: audio codec signaled in quality level, but no embedded audio track signaled, create one'
  439. );
  440. audioTracks.unshift({
  441. type: 'main',
  442. name: 'main',
  443. default: false,
  444. autoselect: false,
  445. forced: false,
  446. id: -1,
  447. attrs: new AttrList({}),
  448. bitrate: 0,
  449. url: '',
  450. });
  451. }
  452. }
  453.  
  454. hls.trigger(Events.MANIFEST_LOADED, {
  455. levels,
  456. audioTracks,
  457. subtitles,
  458. captions,
  459. url,
  460. stats,
  461. networkDetails,
  462. sessionData,
  463. sessionKeys,
  464. });
  465. }
  466.  
  467. private handleTrackOrLevelPlaylist(
  468. response: LoaderResponse,
  469. stats: LoaderStats,
  470. context: PlaylistLoaderContext,
  471. networkDetails: any
  472. ): void {
  473. const hls = this.hls;
  474. const { id, level, type } = context;
  475.  
  476. const url = getResponseUrl(response, context);
  477. const levelUrlId = Number.isFinite(id as number) ? id : 0;
  478. const levelId = Number.isFinite(level as number) ? level : levelUrlId;
  479. const levelType = mapContextToLevelType(context);
  480. const levelDetails: LevelDetails = M3U8Parser.parseLevelPlaylist(
  481. response.data as string,
  482. url,
  483. levelId!,
  484. levelType,
  485. levelUrlId!
  486. );
  487.  
  488. if (!levelDetails.fragments.length) {
  489. hls.trigger(Events.ERROR, {
  490. type: ErrorTypes.NETWORK_ERROR,
  491. details: ErrorDetails.LEVEL_EMPTY_ERROR,
  492. fatal: false,
  493. url: url,
  494. reason: 'no fragments found in level',
  495. level: typeof context.level === 'number' ? context.level : undefined,
  496. });
  497. return;
  498. }
  499.  
  500. // We have done our first request (Manifest-type) and receive
  501. // not a master playlist but a chunk-list (track/level)
  502. // We fire the manifest-loaded event anyway with the parsed level-details
  503. // by creating a single-level structure for it.
  504. if (type === PlaylistContextType.MANIFEST) {
  505. const singleLevel: LevelParsed = {
  506. attrs: new AttrList({}),
  507. bitrate: 0,
  508. details: levelDetails,
  509. name: '',
  510. url,
  511. };
  512.  
  513. hls.trigger(Events.MANIFEST_LOADED, {
  514. levels: [singleLevel],
  515. audioTracks: [],
  516. url,
  517. stats,
  518. networkDetails,
  519. sessionData: null,
  520. sessionKeys: null,
  521. });
  522. }
  523.  
  524. // save parsing time
  525. stats.parsing.end = performance.now();
  526.  
  527. // in case we need SIDX ranges
  528. // return early after calling load for
  529. // the SIDX box.
  530. if (levelDetails.needSidxRanges) {
  531. const sidxUrl = levelDetails.fragments[0].initSegment?.url as string;
  532. this.load({
  533. url: sidxUrl,
  534. isSidxRequest: true,
  535. type,
  536. level,
  537. levelDetails,
  538. id,
  539. groupId: null,
  540. rangeStart: 0,
  541. rangeEnd: 2048,
  542. responseType: 'arraybuffer',
  543. deliveryDirectives: null,
  544. });
  545. return;
  546. }
  547.  
  548. // extend the context with the new levelDetails property
  549. context.levelDetails = levelDetails;
  550.  
  551. this.handlePlaylistLoaded(response, stats, context, networkDetails);
  552. }
  553.  
  554. private handleSidxRequest(
  555. response: LoaderResponse,
  556. context: PlaylistLoaderContext
  557. ): void {
  558. const data = new Uint8Array(response.data as ArrayBuffer);
  559. const sidxBox = findBox(data, ['sidx'])[0];
  560. // if provided fragment does not contain sidx, early return
  561. if (!sidxBox) {
  562. return;
  563. }
  564. const sidxInfo = parseSegmentIndex(sidxBox);
  565. if (!sidxInfo) {
  566. return;
  567. }
  568. const sidxReferences = sidxInfo.references;
  569. const levelDetails = context.levelDetails as LevelDetails;
  570. sidxReferences.forEach((segmentRef, index) => {
  571. const segRefInfo = segmentRef.info;
  572. const frag = levelDetails.fragments[index];
  573. if (!frag) {
  574. logger.error(`no fragment for sidx index ${index}`);
  575. return;
  576. }
  577. if (frag.byteRange.length === 0) {
  578. frag.setByteRange(
  579. String(1 + segRefInfo.end - segRefInfo.start) +
  580. '@' +
  581. String(segRefInfo.start)
  582. );
  583. }
  584. if (frag.initSegment) {
  585. const moovBox = findBox(data, ['moov'])[0];
  586. const moovEndOffset = moovBox ? moovBox.length : null;
  587. frag.initSegment.setByteRange(String(moovEndOffset) + '@0');
  588. }
  589. });
  590. }
  591.  
  592. private handleManifestParsingError(
  593. response: LoaderResponse,
  594. context: PlaylistLoaderContext,
  595. reason: string,
  596. networkDetails: any
  597. ): void {
  598. this.hls.trigger(Events.ERROR, {
  599. type: ErrorTypes.NETWORK_ERROR,
  600. details: ErrorDetails.MANIFEST_PARSING_ERROR,
  601. fatal: context.type === PlaylistContextType.MANIFEST,
  602. url: response.url,
  603. reason,
  604. response,
  605. context,
  606. networkDetails,
  607. });
  608. }
  609.  
  610. private handleNetworkError(
  611. context: PlaylistLoaderContext,
  612. networkDetails: any,
  613. timeout = false,
  614. response?: LoaderResponse
  615. ): void {
  616. logger.warn(
  617. `[playlist-loader]: A network ${
  618. timeout ? 'timeout' : 'error'
  619. } occurred while loading ${context.type} level: ${context.level} id: ${
  620. context.id
  621. } group-id: "${context.groupId}"`
  622. );
  623. let details = ErrorDetails.UNKNOWN;
  624. let fatal = false;
  625.  
  626. const loader = this.getInternalLoader(context);
  627.  
  628. switch (context.type) {
  629. case PlaylistContextType.MANIFEST:
  630. details = timeout
  631. ? ErrorDetails.MANIFEST_LOAD_TIMEOUT
  632. : ErrorDetails.MANIFEST_LOAD_ERROR;
  633. fatal = true;
  634. break;
  635. case PlaylistContextType.LEVEL:
  636. details = timeout
  637. ? ErrorDetails.LEVEL_LOAD_TIMEOUT
  638. : ErrorDetails.LEVEL_LOAD_ERROR;
  639. fatal = false;
  640. break;
  641. case PlaylistContextType.AUDIO_TRACK:
  642. details = timeout
  643. ? ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT
  644. : ErrorDetails.AUDIO_TRACK_LOAD_ERROR;
  645. fatal = false;
  646. break;
  647. case PlaylistContextType.SUBTITLE_TRACK:
  648. details = timeout
  649. ? ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT
  650. : ErrorDetails.SUBTITLE_LOAD_ERROR;
  651. fatal = false;
  652. break;
  653. }
  654.  
  655. if (loader) {
  656. this.resetInternalLoader(context.type);
  657. }
  658.  
  659. const errorData: ErrorData = {
  660. type: ErrorTypes.NETWORK_ERROR,
  661. details,
  662. fatal,
  663. url: context.url,
  664. loader,
  665. context,
  666. networkDetails,
  667. };
  668.  
  669. if (response) {
  670. errorData.response = response;
  671. }
  672.  
  673. this.hls.trigger(Events.ERROR, errorData);
  674. }
  675.  
  676. private handlePlaylistLoaded(
  677. response: LoaderResponse,
  678. stats: LoaderStats,
  679. context: PlaylistLoaderContext,
  680. networkDetails: any
  681. ): void {
  682. const {
  683. type,
  684. level,
  685. id,
  686. groupId,
  687. loader,
  688. levelDetails,
  689. deliveryDirectives,
  690. } = context;
  691.  
  692. if (!levelDetails?.targetduration) {
  693. this.handleManifestParsingError(
  694. response,
  695. context,
  696. 'invalid target duration',
  697. networkDetails
  698. );
  699. return;
  700. }
  701. if (!loader) {
  702. return;
  703. }
  704.  
  705. if (levelDetails.live) {
  706. if (loader.getCacheAge) {
  707. levelDetails.ageHeader = loader.getCacheAge() || 0;
  708. }
  709. if (!loader.getCacheAge || isNaN(levelDetails.ageHeader)) {
  710. levelDetails.ageHeader = 0;
  711. }
  712. }
  713.  
  714. switch (type) {
  715. case PlaylistContextType.MANIFEST:
  716. case PlaylistContextType.LEVEL:
  717. this.hls.trigger(Events.LEVEL_LOADED, {
  718. details: levelDetails,
  719. level: level || 0,
  720. id: id || 0,
  721. stats,
  722. networkDetails,
  723. deliveryDirectives,
  724. });
  725. break;
  726. case PlaylistContextType.AUDIO_TRACK:
  727. this.hls.trigger(Events.AUDIO_TRACK_LOADED, {
  728. details: levelDetails,
  729. id: id || 0,
  730. groupId: groupId || '',
  731. stats,
  732. networkDetails,
  733. deliveryDirectives,
  734. });
  735. break;
  736. case PlaylistContextType.SUBTITLE_TRACK:
  737. this.hls.trigger(Events.SUBTITLE_TRACK_LOADED, {
  738. details: levelDetails,
  739. id: id || 0,
  740. groupId: groupId || '',
  741. stats,
  742. networkDetails,
  743. deliveryDirectives,
  744. });
  745. break;
  746. }
  747. }
  748. }
  749.  
  750. export default PlaylistLoader;