/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

goog.provide('shaka.util.StreamUtils');

goog.require('goog.asserts');
goog.require('shaka.config.AutoShowText');
goog.require('shaka.device.DeviceFactory');
goog.require('shaka.device.IDevice');
goog.require('shaka.lcevc.Dec');
goog.require('shaka.log');
goog.require('shaka.media.Capabilities');
goog.require('shaka.text.TextEngine');
goog.require('shaka.util.Functional');
goog.require('shaka.util.LanguageUtils');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.MimeUtils');
goog.require('shaka.util.MultiMap');
goog.require('shaka.util.ObjectUtils');
goog.requireType('shaka.drm.DrmEngine');


/**
 * @summary A set of utility functions for dealing with Streams and Manifests.
 * @export
 */
shaka.util.StreamUtils = class {
  /**
   * In case of multiple usable codecs, choose one based on lowest average
   * bandwidth and filter out the rest.
   * Also filters out variants that have too many audio channels.
   * @param {!shaka.extern.Manifest} manifest
   * @param {!Array<string>} preferredVideoCodecs
   * @param {!Array<string>} preferredAudioCodecs
   * @param {!Array<string>} preferredDecodingAttributes
   * @param {!Array<string>} preferredTextFormats
   */
  static chooseCodecsAndFilterManifest(manifest, preferredVideoCodecs,
      preferredAudioCodecs, preferredDecodingAttributes, preferredTextFormats) {
    const StreamUtils = shaka.util.StreamUtils;
    const MimeUtils = shaka.util.MimeUtils;

    if (preferredTextFormats.length) {
      let subset = manifest.textStreams;
      for (const textFormat of preferredTextFormats) {
        const filtered = subset.filter((textStream) => {
          if (textStream.codecs.startsWith(textFormat) ||
              textStream.mimeType.startsWith(textFormat)) {
            return true;
          }
          return false;
        });
        if (filtered.length) {
          subset = filtered;
          break;
        }
      }
      manifest.textStreams = subset;
    }

    let variants = manifest.variants;
    // To start, choose the codecs based on configured preferences if available.
    if (preferredVideoCodecs.length || preferredAudioCodecs.length) {
      variants = StreamUtils.choosePreferredCodecs(variants,
          preferredVideoCodecs, preferredAudioCodecs);
    }

    if (preferredDecodingAttributes.length) {
      // group variants by resolution and choose preferred variants only
      /** @type {!shaka.util.MultiMap<shaka.extern.Variant>} */
      const variantsByResolutionMap = new shaka.util.MultiMap();
      for (const variant of variants) {
        variantsByResolutionMap
            .push(String(variant.video.width || 0), variant);
      }
      const bestVariants = [];
      variantsByResolutionMap.forEach((width, variantsByResolution) => {
        let highestMatch = 0;
        let matchingVariants = [];
        for (const variant of variantsByResolution) {
          const matchCount = preferredDecodingAttributes.filter(
              (attribute) => variant.decodingInfos[0][attribute],
          ).length;
          if (matchCount > highestMatch) {
            highestMatch = matchCount;
            matchingVariants = [variant];
          } else if (matchCount == highestMatch) {
            matchingVariants.push(variant);
          }
        }
        bestVariants.push(...matchingVariants);
      });
      variants = bestVariants;
    }

    const audioStreamsSet = new Set();
    const videoStreamsSet = new Set();
    for (const variant of variants) {
      if (variant.audio) {
        audioStreamsSet.add(variant.audio);
      }
      if (variant.video) {
        videoStreamsSet.add(variant.video);
      }
    }

    const audioStreams = Array.from(audioStreamsSet).sort((v1, v2) => {
      return v1.bandwidth - v2.bandwidth;
    });
    const validAudioIds = [];
    const validAudioStreamsMap = new Map();
    const getAudioId = (stream) => {
      let id = stream.language + (stream.channelsCount || 0) +
        (stream.audioSamplingRate || 0) + stream.roles.join(',') +
        stream.label + stream.groupId + stream.fastSwitching;
      if (stream.dependencyStream) {
        id += stream.dependencyStream.baseOriginalId || '';
      }
      return id;
    };
    for (const stream of audioStreams) {
      const groupId = getAudioId(stream);
      const validAudioStreams = validAudioStreamsMap.get(groupId) || [];
      if (!validAudioStreams.length) {
        validAudioStreams.push(stream);
        validAudioIds.push(stream.id);
      } else {
        const previousStream = validAudioStreams[validAudioStreams.length - 1];
        const previousCodec =
          MimeUtils.getNormalizedCodec(previousStream.codecs);
        const currentCodec =
          MimeUtils.getNormalizedCodec(stream.codecs);
        if (previousCodec == currentCodec) {
          if (!stream.bandwidth || !previousStream.bandwidth ||
              stream.bandwidth > previousStream.bandwidth) {
            validAudioStreams.push(stream);
            validAudioIds.push(stream.id);
          }
        }
      }
      validAudioStreamsMap.set(groupId, validAudioStreams);
    }

    // Keys based in MimeUtils.getNormalizedCodec. Lower is better
    const videoCodecPreference = {
      'vp8': 1,
      'avc': 1,
      'dovi-avc': 0.95,
      'vp9': 0.9,
      'vp09': 0.9,
      'hevc': 0.85,
      'dovi-hevc': 0.8,
      'dovi-p5': 0.75,
      'av01': 0.7,
      'dovi-av1': 0.65,
      'vvc': 0.6,
    };

    const videoStreams = Array.from(videoStreamsSet)
        .sort((v1, v2) => {
          if (!v1.bandwidth || !v2.bandwidth || v1.bandwidth == v2.bandwidth) {
            if (v1.codecs && v2.codecs && v1.codecs != v2.codecs &&
                v1.width == v2.width) {
              const v1Codecs = MimeUtils.getNormalizedCodec(v1.codecs);
              const v2Codecs = MimeUtils.getNormalizedCodec(v2.codecs);
              if (v1Codecs != v2Codecs) {
                const indexV1 = videoCodecPreference[v1Codecs] || 1;
                const indexV2 = videoCodecPreference[v2Codecs] || 1;
                return indexV1 - indexV2;
              }
            }
            return v1.width - v2.width;
          }
          return v1.bandwidth - v2.bandwidth;
        });

    const isChangeTypeSupported =
      shaka.media.Capabilities.isChangeTypeSupported();

    const validVideoIds = [];
    const validVideoStreamsMap = new Map();
    const getVideoGroupId = (stream) => {
      let id = String(stream.width || '') + String(stream.height || '') +
          String(Math.round(stream.frameRate || 0)) + (stream.hdr || '') +
          stream.fastSwitching;
      if (stream.dependencyStream) {
        id += stream.dependencyStream.baseOriginalId || '';
      }
      if (stream.roles) {
        id += stream.roles.sort().join('_');
      }
      return id;
    };
    for (const stream of videoStreams) {
      const groupId = getVideoGroupId(stream);
      const validVideoStreams = validVideoStreamsMap.get(groupId) || [];
      if (!validVideoStreams.length) {
        validVideoStreams.push(stream);
        validVideoIds.push(stream.id);
      } else {
        const previousStream = validVideoStreams[validVideoStreams.length - 1];
        if (!isChangeTypeSupported) {
          const previousCodec =
            MimeUtils.getNormalizedCodec(previousStream.codecs);
          const currentCodec =
            MimeUtils.getNormalizedCodec(stream.codecs);
          if (previousCodec !== currentCodec) {
            continue;
          }
        }
        const previousCodec =
          MimeUtils.getNormalizedCodec(previousStream.codecs);
        const currentCodec =
          MimeUtils.getNormalizedCodec(stream.codecs);
        if (previousCodec == currentCodec) {
          if (!stream.bandwidth || !previousStream.bandwidth ||
              stream.bandwidth > previousStream.bandwidth) {
            validVideoStreams.push(stream);
            validVideoIds.push(stream.id);
          }
        }
      }
      validVideoStreamsMap.set(groupId, validVideoStreams);
    }

    // Filter out any variants that don't match, forcing AbrManager to choose
    // from a single video codec and a single audio codec possible.
    manifest.variants = manifest.variants.filter((variant) => {
      const audio = variant.audio;
      const video = variant.video;
      if (audio) {
        if (!validAudioIds.includes(audio.id)) {
          shaka.log.debug('Dropping Variant (better codec available)', variant);
          return false;
        }
      }
      if (video) {
        if (!validVideoIds.includes(video.id)) {
          shaka.log.debug('Dropping Variant (better codec available)', variant);
          return false;
        }
      }
      return true;
    });
  }

  /**
   * Choose the codecs by configured preferred audio and video codecs.
   *
   * @param {!Array<shaka.extern.Variant>} variants
   * @param {!Array<string>} preferredVideoCodecs
   * @param {!Array<string>} preferredAudioCodecs
   * @return {!Array<shaka.extern.Variant>}
   */
  static choosePreferredCodecs(variants, preferredVideoCodecs,
      preferredAudioCodecs) {
    let subset = variants;
    for (const videoCodec of preferredVideoCodecs) {
      const filtered = subset.filter((variant) => {
        return variant.video && variant.video.codecs.startsWith(videoCodec);
      });
      if (filtered.length) {
        subset = filtered;
        break;
      }
    }

    for (const audioCodec of preferredAudioCodecs) {
      const filtered = subset.filter((variant) => {
        return variant.audio && variant.audio.codecs.startsWith(audioCodec);
      });
      if (filtered.length) {
        subset = filtered;
        break;
      }
    }
    return subset;
  }

  /**
   * Filter the variants in |manifest| to only include the variants that meet
   * the given restrictions.
   *
   * @param {!shaka.extern.Manifest} manifest
   * @param {shaka.extern.Restrictions} restrictions
   * @param {shaka.extern.Resolution} maxHwResolution
   */
  static filterByRestrictions(manifest, restrictions, maxHwResolution) {
    manifest.variants = manifest.variants.filter((variant) => {
      return shaka.util.StreamUtils.meetsRestrictions(
          variant, restrictions, maxHwResolution);
    });
  }

  /**
   * @param {shaka.extern.Variant} variant
   * @param {shaka.extern.Restrictions} restrictions
   *   Configured restrictions from the user.
   * @param {shaka.extern.Resolution} maxHwRes
   *   The maximum resolution the hardware can handle.
   *   This is applied separately from user restrictions because the setting
   *   should not be easily replaced by the user's configuration.
   * @return {boolean}
   * @export
   */
  static meetsRestrictions(variant, restrictions, maxHwRes) {
    /** @type {function(number, number, number):boolean} */
    const inRange = (x, min, max) => {
      return x >= min && x <= max;
    };

    const video = variant.video;

    // |video.width| and |video.height| can be undefined, which breaks
    // the math, so make sure they are there first.
    if (video && video.width && video.height) {
      let videoWidth = video.width;
      let videoHeight = video.height;
      if (videoHeight > videoWidth) {
        // Vertical video.
        [videoWidth, videoHeight] = [videoHeight, videoWidth];
      }

      if (!inRange(videoWidth,
          restrictions.minWidth,
          Math.min(restrictions.maxWidth, maxHwRes.width))) {
        return false;
      }

      if (!inRange(videoHeight,
          restrictions.minHeight,
          Math.min(restrictions.maxHeight, maxHwRes.height))) {
        return false;
      }

      if (!inRange(video.width * video.height,
          restrictions.minPixels,
          restrictions.maxPixels)) {
        return false;
      }
    }

    // |variant.video.frameRate| can be undefined, which breaks
    // the math, so make sure they are there first.
    if (variant && variant.video && variant.video.frameRate) {
      if (!inRange(variant.video.frameRate,
          restrictions.minFrameRate,
          restrictions.maxFrameRate)) {
        return false;
      }
    }

    // |variant.audio.channelsCount| can be undefined, which breaks
    // the math, so make sure they are there first.
    if (variant && variant.audio && variant.audio.channelsCount) {
      if (!inRange(variant.audio.channelsCount,
          restrictions.minChannelsCount,
          restrictions.maxChannelsCount)) {
        return false;
      }
    }

    if (!inRange(variant.bandwidth,
        restrictions.minBandwidth,
        restrictions.maxBandwidth)) {
      return false;
    }

    return true;
  }


  /**
   * @param {!Array<shaka.extern.Variant>} variants
   * @param {shaka.extern.Restrictions} restrictions
   * @param {shaka.extern.Resolution} maxHwRes
   * @return {boolean} Whether the tracks changed.
   */
  static applyRestrictions(variants, restrictions, maxHwRes) {
    let tracksChanged = false;

    for (const variant of variants) {
      const originalAllowed = variant.allowedByApplication;
      variant.allowedByApplication = shaka.util.StreamUtils.meetsRestrictions(
          variant, restrictions, maxHwRes);

      if (originalAllowed != variant.allowedByApplication) {
        tracksChanged = true;
      }
    }

    return tracksChanged;
  }


  /**
   * Alters the given Manifest to filter out any unplayable streams.
   *
   * @param {shaka.drm.DrmEngine} drmEngine
   * @param {shaka.extern.Manifest} manifest
   * @param {!Array<string>=} preferredKeySystems
   * @param {!Object<string, string>=} keySystemsMapping
   */
  static async filterManifest(drmEngine, manifest, preferredKeySystems = [],
      keySystemsMapping = {}) {
    await shaka.util.StreamUtils.filterManifestByMediaCapabilities(
        drmEngine, manifest, manifest.offlineSessionIds.length > 0,
        preferredKeySystems, keySystemsMapping);
    shaka.util.StreamUtils.filterTextStreams_(manifest);
    await shaka.util.StreamUtils.filterImageStreams_(manifest);
  }


  /**
   * Alters the given Manifest to filter out any streams unsupported by the
   * platform via MediaCapabilities.decodingInfo() API.
   *
   * @param {shaka.drm.DrmEngine} drmEngine
   * @param {shaka.extern.Manifest} manifest
   * @param {boolean} usePersistentLicenses
   * @param {!Array<string>} preferredKeySystems
   * @param {!Object<string, string>} keySystemsMapping
   */
  static async filterManifestByMediaCapabilities(
      drmEngine, manifest, usePersistentLicenses, preferredKeySystems,
      keySystemsMapping) {
    goog.asserts.assert(navigator.mediaCapabilities,
        'MediaCapabilities should be valid.');

    if (shaka.device.DeviceFactory.getDevice()
        .shouldOverrideDolbyVisionCodecs()) {
      shaka.util.StreamUtils.overrideDolbyVisionCodecs(manifest.variants);
    }
    await shaka.util.StreamUtils.getDecodingInfosForVariants(
        manifest.variants, usePersistentLicenses, /* srcEquals= */ false,
        preferredKeySystems);

    let keySystem = null;
    if (drmEngine) {
      const drmInfo = drmEngine.getDrmInfo();
      if (drmInfo) {
        keySystem = drmInfo.keySystem;
      }
    }

    const StreamUtils = shaka.util.StreamUtils;

    manifest.variants = manifest.variants.filter((variant) => {
      const supported = StreamUtils.checkVariantSupported_(
          variant, keySystem, keySystemsMapping);
      // Filter out all unsupported variants.
      if (!supported) {
        shaka.log.debug('Dropping variant - not compatible with platform',
            StreamUtils.getVariantSummaryString_(variant));
      }
      return supported;
    });
  }


  /**
   * Maps Dolby Vision codecs to H.264 and H.265 equivalents as a workaround
   * to make Dolby Vision playback work on some platforms.
   *
   * Mapping is done according to the relevant Dolby documentation found here:
   * https://professionalsupport.dolby.com/s/article/How-to-signal-Dolby-Vision-in-MPEG-DASH?language=en_US
   * @param {!Array<!shaka.extern.Variant>} variants
   */
  static overrideDolbyVisionCodecs(variants) {
    /** @type {!Map<string, string>} */
    const codecMap = new Map()
        .set('dvav', 'avc3')
        .set('dva1', 'avc1')
        .set('dvhe', 'hev1')
        .set('dvh1', 'hvc1')
        .set('dvc1', 'vvc1')
        .set('dvi1', 'vvi1');

    /** @type {!Set<!shaka.extern.Stream>} */
    const streams = new Set();
    for (const variant of variants) {
      if (variant.video) {
        streams.add(variant.video);
      }
    }
    for (const video of streams) {
      for (const [dvCodec, replacement] of codecMap) {
        if (video.codecs.includes(dvCodec)) {
          video.codecs = video.codecs.replace(dvCodec, replacement);
          break;
        }
      }
    }
  }


  /**
   * @param {!shaka.extern.Variant} variant
   * @param {?string} keySystem
   * @param {!Object<string, string>} keySystemsMapping
   * @return {boolean}
   * @private
   */
  static checkVariantSupported_(variant, keySystem, keySystemsMapping) {
    const variantSupported = variant.decodingInfos.some((decodingInfo) => {
      if (!decodingInfo.supported) {
        return false;
      }
      if (keySystem) {
        const keySystemAccess = decodingInfo.keySystemAccess;
        if (keySystemAccess) {
          const currentKeySystem =
              keySystemsMapping[keySystemAccess.keySystem] ||
              keySystemAccess.keySystem;
          if (currentKeySystem != keySystem) {
            return false;
          }
        }
      }
      return true;
    });
    if (!variantSupported) {
      return false;
    }

    const device = shaka.device.DeviceFactory.getDevice();
    const isXboxOne = device.getDeviceName() === 'Xbox';
    const isFirefoxAndroid =
        device.getDeviceType() === shaka.device.IDevice.DeviceType.MOBILE &&
        device.getBrowserEngine() === shaka.device.IDevice.BrowserEngine.GECKO;

    // See: https://github.com/shaka-project/shaka-player/issues/3860
    const video = variant.video;
    const videoWidth = (video && video.width) || 0;
    const videoHeight = (video && video.height) || 0;

    // See: https://github.com/shaka-project/shaka-player/issues/3380
    // Note: it makes sense to drop early
    if (isXboxOne && video && (videoWidth > 1920 || videoHeight > 1080) &&
        (video.codecs.includes('avc1.') || video.codecs.includes('avc3.'))) {
      return false;
    }

    const videoDependencyStream = video && video.dependencyStream;
    if (videoDependencyStream &&
        !shaka.lcevc.Dec.isStreamSupported(videoDependencyStream)) {
      return false;
    }

    const audio = variant.audio;

    // See: https://github.com/shaka-project/shaka-player/issues/6111
    // It seems that Firefox Android reports that it supports
    // Opus + Widevine, but it is not actually supported.
    // It makes sense to drop early.
    if (isFirefoxAndroid && audio && audio.encrypted &&
        audio.codecs.toLowerCase().includes('opus')) {
      return false;
    }

    const audioDependencyStream = audio && audio.dependencyStream;
    if (audioDependencyStream) {
      return false;
    }

    return true;
  }


  /**
   * Queries mediaCapabilities for the decoding info for that decoding config,
   * and assigns it to the given variant.
   * If that query has been done before, instead return a cached result.
   * @param {!shaka.extern.Variant} variant
   * @param {!Array<!MediaDecodingConfiguration>} decodingConfigs
   * @private
   */
  static async getDecodingInfosForVariant_(variant, decodingConfigs) {
    /**
     * @param {?MediaCapabilitiesDecodingInfo} a
     * @param {!MediaCapabilitiesDecodingInfo} b
     * @return {!MediaCapabilitiesDecodingInfo}
     */
    const merge = (a, b) => {
      if (!a) {
        return b;
      } else {
        const res = shaka.util.ObjectUtils.shallowCloneObject(a);
        res.supported = a.supported && b.supported;
        res.powerEfficient = a.powerEfficient && b.powerEfficient;
        res.smooth = a.smooth && b.smooth;
        if (b.keySystemAccess && !res.keySystemAccess) {
          res.keySystemAccess = b.keySystemAccess;
        }
        return res;
      }
    };

    const StreamUtils = shaka.util.StreamUtils;
    /** @type {?MediaCapabilitiesDecodingInfo} */
    let finalResult = null;
    const promises = [];
    for (const decodingConfig of decodingConfigs) {
      const cacheKey =
          shaka.util.ObjectUtils.alphabeticalKeyOrderStringify(decodingConfig);

      const cache = StreamUtils.decodingConfigCache_;
      if (cache.has(cacheKey)) {
        shaka.log.v2('Using cached results of mediaCapabilities.decodingInfo',
            'for key', cacheKey);
        finalResult = merge(finalResult, cache.get(cacheKey));
      } else {
        // Do a final pass-over of the decoding config: if a given stream has
        // multiple codecs, that suggests that it switches between those codecs
        // at points of the go-through.
        // mediaCapabilities by itself will report "not supported" when you
        // put in multiple different codecs, so each has to be checked
        // individually. So check each and take the worst result, to determine
        // overall variant compatibility.
        promises.push(StreamUtils
            .checkEachDecodingConfigCombination_(decodingConfig).then((res) => {
              /** @type {?MediaCapabilitiesDecodingInfo} */
              let acc = null;
              for (const result of (res || [])) {
                acc = merge(acc, result);
              }
              if (acc) {
                cache.set(cacheKey, acc);
                finalResult = merge(finalResult, acc);
              }
            }));
      }
    }
    await Promise.all(promises);
    if (finalResult) {
      variant.decodingInfos.push(finalResult);
    }
  }

  /**
   * @param {!MediaDecodingConfiguration} decodingConfig
   * @return {!Promise<?Array<!MediaCapabilitiesDecodingInfo>>}
   * @private
   */
  static checkEachDecodingConfigCombination_(decodingConfig) {
    let videoCodecs = [''];
    if (decodingConfig.video) {
      videoCodecs = shaka.util.MimeUtils.getCodecs(
          decodingConfig.video.contentType).split(',');
    }
    let audioCodecs = [''];
    if (decodingConfig.audio) {
      audioCodecs = shaka.util.MimeUtils.getCodecs(
          decodingConfig.audio.contentType).split(',');
    }
    const promises = [];
    for (const videoCodec of videoCodecs) {
      for (const audioCodec of audioCodecs) {
        const copy = shaka.util.ObjectUtils.cloneObject(decodingConfig);
        if (decodingConfig.video) {
          const mimeType = shaka.util.MimeUtils.getBasicType(
              copy.video.contentType);
          copy.video.contentType = shaka.util.MimeUtils.getFullType(
              mimeType, videoCodec);
        }
        if (decodingConfig.audio) {
          const mimeType = shaka.util.MimeUtils.getBasicType(
              copy.audio.contentType);
          copy.audio.contentType = shaka.util.MimeUtils.getFullType(
              mimeType, audioCodec);
        }
        promises.push(new Promise((resolve, reject) => {
          // On some (Android) WebView environments, decodingInfo will
          // not resolve or reject, at least if RESOURCE_PROTECTED_MEDIA_ID
          // is not set.  This is a workaround for that issue.
          const TIMEOUT_FOR_DECODING_INFO_IN_SECONDS = 5;
          let promise;
          const device = shaka.device.DeviceFactory.getDevice();
          if (device.getDeviceType() ==
              shaka.device.IDevice.DeviceType.MOBILE) {
            promise = shaka.util.Functional.promiseWithTimeout(
                TIMEOUT_FOR_DECODING_INFO_IN_SECONDS,
                navigator.mediaCapabilities.decodingInfo(copy),
            );
          } else {
            promise = navigator.mediaCapabilities.decodingInfo(copy);
          }
          promise.then((res) => {
            resolve(res);
          }).catch(reject);
        }));
      }
    }
    return Promise.all(promises).catch((e) => {
      shaka.log.info('MediaCapabilities.decodingInfo() failed.',
          JSON.stringify(decodingConfig), e);
      return null;
    });
  }


  /**
   * Get the decodingInfo results of the variants via MediaCapabilities.
   * This should be called after the DrmEngine is created and configured, and
   * before DrmEngine sets the mediaKeys.
   *
   * @param {!Array<shaka.extern.Variant>} variants
   * @param {boolean} usePersistentLicenses
   * @param {boolean} srcEquals
   * @param {!Array<string>} preferredKeySystems
   * @exportDoc
   */
  static async getDecodingInfosForVariants(variants, usePersistentLicenses,
      srcEquals, preferredKeySystems) {
    const gotDecodingInfo = variants.some((variant) =>
      variant.decodingInfos.length);
    if (gotDecodingInfo) {
      shaka.log.debug('Already got the variants\' decodingInfo.');
      return;
    }

    // Try to get preferred key systems first to avoid unneeded calls to CDM.
    for (const preferredKeySystem of preferredKeySystems) {
      let keySystemSatisfied = false;
      for (const variant of variants) {
        /** @type {!Array<!Array<!MediaDecodingConfiguration>>} */
        const decodingConfigs = shaka.util.StreamUtils.getDecodingConfigs_(
            variant, usePersistentLicenses, srcEquals)
            .filter((configs) => {
              // All configs in a batch will have the same keySystem.
              const config = configs[0];
              const keySystem = config.keySystemConfiguration &&
                config.keySystemConfiguration.keySystem;
              return keySystem === preferredKeySystem;
            });

        // The reason we are performing this await in a loop rather than
        // batching into a `promise.all` is performance related.
        // https://github.com/shaka-project/shaka-player/pull/4708#discussion_r1022581178
        for (const configs of decodingConfigs) {
          // eslint-disable-next-line no-await-in-loop
          await shaka.util.StreamUtils.getDecodingInfosForVariant_(
              variant, configs);
        }
        if (variant.decodingInfos.some((d) => d.supported)) {
          keySystemSatisfied = true;
        }
      } // for (const variant of variants)
      if (keySystemSatisfied) {
        // Return if any preferred key system is already satisfied.
        return;
      }
    } // for (const preferredKeySystem of preferredKeySystems)

    for (const variant of variants) {
      /** @type {!Array<!Array<!MediaDecodingConfiguration>>} */
      const decodingConfigs = shaka.util.StreamUtils.getDecodingConfigs_(
          variant, usePersistentLicenses, srcEquals)
          .filter((configs) => {
            // All configs in a batch will have the same keySystem.
            const config = configs[0];
            const keySystem = config.keySystemConfiguration &&
              config.keySystemConfiguration.keySystem;
            // Avoid checking preferred systems twice.
            return !keySystem || !preferredKeySystems.includes(keySystem);
          });

      // The reason we are performing this await in a loop rather than
      // batching into a `promise.all` is performance related.
      // https://github.com/shaka-project/shaka-player/pull/4708#discussion_r1022581178
      for (const configs of decodingConfigs) {
        // eslint-disable-next-line no-await-in-loop
        await shaka.util.StreamUtils.getDecodingInfosForVariant_(
            variant, configs);
      }
    }
  }


  /**
   * Generate a batch of MediaDecodingConfiguration objects to get the
   * decodingInfo results for each variant.
   * Each batch shares the same DRM information, and represents the various
   * fullMimeType combinations of the streams.
   * @param {!shaka.extern.Variant} variant
   * @param {boolean} usePersistentLicenses
   * @param {boolean} srcEquals
   * @return {!Array<!Array<!MediaDecodingConfiguration>>}
   * @private
   */
  static getDecodingConfigs_(variant, usePersistentLicenses, srcEquals) {
    const audio = variant.audio;
    const video = variant.video;

    const ContentType = shaka.util.ManifestParserUtils.ContentType;
    const ManifestParserUtils = shaka.util.ManifestParserUtils;
    const MimeUtils = shaka.util.MimeUtils;
    const StreamUtils = shaka.util.StreamUtils;

    const videoConfigs = [];
    const audioConfigs = [];
    if (video) {
      for (const fullMimeType of video.fullMimeTypes) {
        let videoCodecs = MimeUtils.getCodecs(fullMimeType);

        // For multiplexed streams with audio+video codecs, the config should
        // have AudioConfiguration and VideoConfiguration.
        // We ignore the multiplexed audio when there is normal audio also.
        if (videoCodecs.includes(',') && !audio) {
          const allCodecs = videoCodecs.split(',');
          const baseMimeType = MimeUtils.getBasicType(fullMimeType);

          videoCodecs = ManifestParserUtils.guessCodecs(
              ContentType.VIDEO, allCodecs);

          let audioCodecs = ManifestParserUtils.guessCodecs(
              ContentType.AUDIO, allCodecs);
          audioCodecs = StreamUtils.getCorrectAudioCodecs(
              audioCodecs, baseMimeType);

          const audioFullType = MimeUtils.getFullOrConvertedType(
              baseMimeType, audioCodecs, ContentType.AUDIO);

          audioConfigs.push({
            contentType: audioFullType,
            channels: 2,
            bitrate: variant.bandwidth || 1,
            samplerate: 1,
            spatialRendering: false,
          });
        }

        videoCodecs = StreamUtils.getCorrectVideoCodecs(videoCodecs);
        const fullType = MimeUtils.getFullOrConvertedType(
            MimeUtils.getBasicType(fullMimeType), videoCodecs,
            ContentType.VIDEO);

        // VideoConfiguration
        const videoConfig = {
          contentType: fullType,

          // NOTE: Some decoders strictly check the width and height fields and
          // won't decode smaller than 64x64.  So if we don't have this info (as
          // is the case in some of our simpler tests), assume a 64x64
          // resolution to fill in this required field for MediaCapabilities.
          //
          // This became an issue specifically on Firefox on M1 Macs.
          width: video.width || 64,
          height: video.height || 64,

          bitrate: video.bandwidth || variant.bandwidth || 1,
          // framerate must be greater than 0, otherwise the config is invalid.
          framerate: video.frameRate || 30,
        };
        if (video.hdr) {
          // We assume that SDR uses by default srgb, so don't set it.
          switch (video.hdr) {
            case 'PQ':
              videoConfig.transferFunction = 'pq';
              break;
            case 'HLG':
              videoConfig.transferFunction = 'hlg';
              break;
          }
        }
        if (video.colorGamut) {
          videoConfig.colorGamut = video.colorGamut;
        }
        videoConfigs.push(videoConfig);
      }
    }
    if (audio) {
      for (const fullMimeType of audio.fullMimeTypes) {
        const baseMimeType = MimeUtils.getBasicType(fullMimeType);
        const codecs = StreamUtils.getCorrectAudioCodecs(
            MimeUtils.getCodecs(fullMimeType), baseMimeType);
        const fullType = MimeUtils.getFullOrConvertedType(
            baseMimeType, codecs, ContentType.AUDIO);

        // AudioConfiguration
        audioConfigs.push({
          contentType: fullType,
          channels: audio.channelsCount || 2,
          bitrate: audio.bandwidth || variant.bandwidth || 1,
          samplerate: audio.audioSamplingRate || 1,
          spatialRendering: audio.spatialAudio,
        });
      }
    }

    // Generate each combination of video and audio config as a separate
    // MediaDecodingConfiguration, inside the main "batch".
    /** @type {!Array<!MediaDecodingConfiguration>} */
    const mediaDecodingConfigBatch = [];
    if (videoConfigs.length == 0) {
      videoConfigs.push(null);
    }
    if (audioConfigs.length == 0) {
      audioConfigs.push(null);
    }
    for (const videoConfig of videoConfigs) {
      for (const audioConfig of audioConfigs) {
        /** @type {!MediaDecodingConfiguration} */
        const mediaDecodingConfig = {
          type: srcEquals ? 'file' : 'media-source',
        };
        if (videoConfig) {
          mediaDecodingConfig.video = videoConfig;
        }
        if (audioConfig) {
          mediaDecodingConfig.audio = audioConfig;
        }
        mediaDecodingConfigBatch.push(mediaDecodingConfig);
      }
    }

    const videoDrmInfos = variant.video ? variant.video.drmInfos : [];
    const audioDrmInfos = variant.audio ? variant.audio.drmInfos : [];
    const allDrmInfos = videoDrmInfos.concat(audioDrmInfos);

    // Return a list containing the mediaDecodingConfig for unencrypted variant.
    if (!allDrmInfos.length) {
      return [mediaDecodingConfigBatch];
    }

    // A list of MediaDecodingConfiguration objects created for the variant.
    const configs = [];

    // Get all the drm info so that we can avoid using nested loops when we
    // just need the drm info.
    const drmInfoByKeySystems = new Map();
    for (const info of allDrmInfos) {
      if (!drmInfoByKeySystems.get(info.keySystem)) {
        drmInfoByKeySystems.set(info.keySystem, []);
      }
      drmInfoByKeySystems.get(info.keySystem).push(info);
    }

    const persistentState =
        usePersistentLicenses ? 'required' : 'optional';
    const sessionTypes =
        usePersistentLicenses ? ['persistent-license'] : ['temporary'];

    for (const keySystem of drmInfoByKeySystems.keys()) {
      const drmInfos = drmInfoByKeySystems.get(keySystem);

      // Get all the robustness info so that we can avoid using nested
      // loops when we just need the robustness.
      const drmInfosByRobustness = new Map();
      for (const info of drmInfos) {
        const keyName = `${info.videoRobustness},${info.audioRobustness}`;
        if (!drmInfosByRobustness.get(keyName)) {
          drmInfosByRobustness.set(keyName, []);
        }
        drmInfosByRobustness.get(keyName).push(info);
      }

      for (const drmInfosRobustness of drmInfosByRobustness.values()) {
        const modifiedMediaDecodingConfigBatch = [];
        for (const base of mediaDecodingConfigBatch) {
          // Create a copy of the mediaDecodingConfig.
          const config = /** @type {!MediaDecodingConfiguration} */
              (Object.assign({}, base));


          /** @type {!MediaCapabilitiesKeySystemConfiguration} */
          const keySystemConfig = {
            keySystem: keySystem,
            initDataType: 'cenc',
            persistentState: persistentState,
            distinctiveIdentifier: 'optional',
            sessionTypes: sessionTypes,
          };

          for (const info of drmInfosRobustness) {
            if (info.initData && info.initData.length) {
              const initDataTypes = new Set();
              for (const initData of info.initData) {
                initDataTypes.add(initData.initDataType);
              }
              if (initDataTypes.size > 1) {
                shaka.log.v2('DrmInfo contains more than one initDataType,',
                    'and we use the initDataType of the first initData.',
                    info);
              }
              keySystemConfig.initDataType = info.initData[0].initDataType;
            }

            if (info.distinctiveIdentifierRequired) {
              keySystemConfig.distinctiveIdentifier = 'required';
            }
            if (info.persistentStateRequired) {
              keySystemConfig.persistentState = 'required';
            }
            if (info.sessionType) {
              keySystemConfig.sessionTypes = [info.sessionType];
            }

            if (audio) {
              if (!keySystemConfig.audio) {
                // KeySystemTrackConfiguration
                keySystemConfig.audio = {
                  robustness: info.audioRobustness,
                };
                if (info.encryptionScheme) {
                  keySystemConfig.audio.encryptionScheme =
                      info.encryptionScheme;
                }
              } else {
                if (info.encryptionScheme) {
                  keySystemConfig.audio.encryptionScheme =
                      keySystemConfig.audio.encryptionScheme ||
                      info.encryptionScheme;
                }
                keySystemConfig.audio.robustness =
                    keySystemConfig.audio.robustness ||
                    info.audioRobustness;
              }
              // See: https://github.com/shaka-project/shaka-player/issues/4659
              if (keySystemConfig.audio.robustness == '') {
                delete keySystemConfig.audio.robustness;
              }
            }

            if (video) {
              if (!keySystemConfig.video) {
                // KeySystemTrackConfiguration
                keySystemConfig.video = {
                  robustness: info.videoRobustness,
                };
                if (info.encryptionScheme) {
                  keySystemConfig.video.encryptionScheme =
                      info.encryptionScheme;
                }
              } else {
                if (info.encryptionScheme) {
                  keySystemConfig.video.encryptionScheme =
                      keySystemConfig.video.encryptionScheme ||
                      info.encryptionScheme;
                }
                keySystemConfig.video.robustness =
                    keySystemConfig.video.robustness ||
                    info.videoRobustness;
              }
              // See: https://github.com/shaka-project/shaka-player/issues/4659
              if (keySystemConfig.video.robustness == '') {
                delete keySystemConfig.video.robustness;
              }
            }
          }
          config.keySystemConfiguration = keySystemConfig;
          modifiedMediaDecodingConfigBatch.push(config);
        }
        configs.push(modifiedMediaDecodingConfigBatch);
      }
    }
    return configs;
  }


  /**
   * Generates the correct audio codec for MediaDecodingConfiguration and
   * for MediaSource.isTypeSupported.
   * @param {string} codecs
   * @param {string} mimeType
   * @return {string}
   */
  static getCorrectAudioCodecs(codecs, mimeType) {
    // According to RFC 6381 section 3.3, 'fLaC' is actually the correct
    // codec string. We still need to map it to 'flac', as some browsers
    // currently don't support 'fLaC', while 'flac' is supported by most
    // major browsers.
    // See https://bugs.chromium.org/p/chromium/issues/detail?id=1422728
    const device = shaka.device.DeviceFactory.getDevice();
    const webkit = shaka.device.IDevice.BrowserEngine.WEBKIT;
    if (codecs.toLowerCase() == 'flac') {
      if (device.getBrowserEngine() != webkit) {
        return 'flac';
      } else {
        return 'fLaC';
      }
    }

    // The same is true for 'Opus'.
    if (codecs.toLowerCase() === 'opus') {
      if (device.getBrowserEngine() != webkit) {
        return 'opus';
      } else {
        if (shaka.util.MimeUtils.getContainerType(mimeType) == 'mp4') {
          return 'Opus';
        } else {
          return 'opus';
        }
      }
    }

    if (codecs.toLowerCase() == 'ac-3' && device.requiresEC3InitSegments()) {
      return 'ec-3';
    }

    return codecs;
  }


  /**
   * Generates the correct video codec for MediaDecodingConfiguration and
   * for MediaSource.isTypeSupported.
   * @param {string} codec
   * @return {string}
   */
  static getCorrectVideoCodecs(codec) {
    if (codec.includes('avc1')) {
      // Convert avc1 codec string from RFC-4281 to RFC-6381 for
      // MediaSource.isTypeSupported
      // Example, convert avc1.66.30 to avc1.42001e (0x42 == 66 and 0x1e == 30)
      const avcData = codec.split('.');
      if (avcData.length == 3) {
        let result = avcData.shift() + '.';
        result += parseInt(avcData.shift(), 10).toString(16);
        result +=
            ('000' + parseInt(avcData.shift(), 10).toString(16)).slice(-4);
        return result;
      }
    } else if (codec == 'vp9') {
      // MediaCapabilities supports 'vp09...' codecs, but not 'vp9'. Translate
      // vp9 codec strings into 'vp09...', to allow such content to play with
      // mediaCapabilities enabled.
      // This means profile 0, level 4.1, 8-bit color.  This supports 1080p @
      // 60Hz.  See https://en.wikipedia.org/wiki/VP9#Levels
      //
      // If we don't have more detailed codec info, assume this profile and
      // level because it's high enough to likely accommodate the parameters we
      // do have, such as width and height.  If an implementation is checking
      // the profile and level very strictly, we want older VP9 content to
      // still work to some degree.  But we don't want to set a level so high
      // that it is rejected by a hardware decoder that can't handle the
      // maximum requirements of the level.
      //
      // This became an issue specifically on Firefox on M1 Macs.
      return 'vp09.00.41.08';
    }
    return codec;
  }


  /**
   * Alters the given Manifest to filter out any streams incompatible with the
   * current variant.
   *
   * @param {?shaka.extern.Variant} currentVariant
   * @param {shaka.extern.Manifest} manifest
   */
  static filterManifestByCurrentVariant(currentVariant, manifest) {
    const StreamUtils = shaka.util.StreamUtils;
    manifest.variants = manifest.variants.filter((variant) => {
      const audio = variant.audio;
      const video = variant.video;
      if (audio && currentVariant && currentVariant.audio) {
        if (!StreamUtils.areStreamsCompatible_(audio, currentVariant.audio)) {
          shaka.log.debug('Dropping variant - not compatible with active audio',
              'active audio',
              StreamUtils.getStreamSummaryString_(currentVariant.audio),
              'variant.audio',
              StreamUtils.getStreamSummaryString_(audio));
          return false;
        }
      }

      if (video && currentVariant && currentVariant.video) {
        if (!StreamUtils.areStreamsCompatible_(video, currentVariant.video)) {
          shaka.log.debug('Dropping variant - not compatible with active video',
              'active video',
              StreamUtils.getStreamSummaryString_(currentVariant.video),
              'variant.video',
              StreamUtils.getStreamSummaryString_(video));
          return false;
        }
      }

      return true;
    });
  }

  /**
   * Alters the given Manifest to filter out any unsupported text streams.
   *
   * @param {shaka.extern.Manifest} manifest
   * @private
   */
  static filterTextStreams_(manifest) {
    // Filter text streams.
    manifest.textStreams = manifest.textStreams.filter((stream) => {
      const fullMimeType = shaka.util.MimeUtils.getFullType(
          stream.mimeType, stream.codecs);
      const keep = shaka.text.TextEngine.isTypeSupported(fullMimeType);

      if (!keep) {
        shaka.log.debug('Dropping text stream. Is not supported by the ' +
                        'platform.', stream);
      }

      return keep;
    });
  }


  /**
   * Alters the given Manifest to filter out any unsupported image streams.
   *
   * @param {shaka.extern.Manifest} manifest
   * @private
   */
  static async filterImageStreams_(manifest) {
    const imageStreams = [];
    for (const stream of manifest.imageStreams) {
      let mimeType = stream.mimeType;
      if (mimeType == 'application/mp4' && stream.codecs == 'mjpg') {
        mimeType = 'image/jpg';
      }
      if (!shaka.util.StreamUtils.supportedImageMimeTypes_.has(mimeType)) {
        const minImage = shaka.util.StreamUtils.minImage_.get(mimeType);
        if (minImage) {
          // eslint-disable-next-line no-await-in-loop
          const res = await shaka.util.StreamUtils.isImageSupported_(minImage);
          shaka.util.StreamUtils.supportedImageMimeTypes_.set(mimeType, res);
        } else {
          shaka.util.StreamUtils.supportedImageMimeTypes_.set(mimeType, false);
        }
      }

      const keep =
          shaka.util.StreamUtils.supportedImageMimeTypes_.get(mimeType);

      if (!keep) {
        shaka.log.debug('Dropping image stream. Is not supported by the ' +
                        'platform.', stream);
      } else {
        imageStreams.push(stream);
      }
    }
    manifest.imageStreams = imageStreams;
  }

  /**
   * @param {string} minImage
   * @return {!Promise<boolean>}
   * @private
   */
  static isImageSupported_(minImage) {
    return new Promise((resolve) => {
      const imageElement = /** @type {HTMLImageElement} */(new Image());
      imageElement.src = minImage;
      if ('decode' in imageElement) {
        imageElement.decode().then(() => {
          resolve(true);
        }).catch(() => {
          resolve(false);
        });
      } else {
        imageElement.onload = imageElement.onerror = () => {
          resolve(imageElement.height === 2);
        };
      }
    });
  }

  /**
   * @param {shaka.extern.Stream} s0
   * @param {shaka.extern.Stream} s1
   * @return {boolean}
   * @private
   */
  static areStreamsCompatible_(s0, s1) {
    // Basic mime types and basic codecs need to match.
    // For example, we can't adapt between WebM and MP4,
    // nor can we adapt between mp4a.* to ec-3.
    // We can switch between text types on the fly,
    // so don't run this check on text.
    if (s0.mimeType != s1.mimeType) {
      return false;
    }

    if (s0.codecs.split('.')[0] != s1.codecs.split('.')[0]) {
      return false;
    }

    return true;
  }


  /**
   * @param {shaka.extern.Variant} variant
   * @return {shaka.extern.Track}
   */
  static variantToTrack(variant) {
    const ManifestParserUtils = shaka.util.ManifestParserUtils;
    const ContentType = shaka.util.ManifestParserUtils.ContentType;

    /** @type {?shaka.extern.Stream} */
    const audio = variant.audio;
    /** @type {?shaka.extern.Stream} */
    const video = variant.video;

    /** @type {?string} */
    const audioMimeType = audio ? audio.mimeType : null;
    /** @type {?string} */
    const videoMimeType = video ? video.mimeType : null;

    /** @type {?string} */
    const audioCodec = audio ? audio.codecs : null;
    /** @type {?string} */
    const videoCodec = video ? video.codecs : null;

    /** @type {?string} */
    const audioGroupId = audio ? audio.groupId : null;

    /** @type {!Array<string>} */
    const mimeTypes = [];
    if (video) {
      mimeTypes.push(video.mimeType);
    }
    if (audio) {
      mimeTypes.push(audio.mimeType);
    }
    /** @type {?string} */
    const mimeType = mimeTypes[0] || null;

    /** @type {!Array<string>} */
    const kinds = [];
    if (audio) {
      kinds.push(audio.kind);
    }
    if (video) {
      kinds.push(video.kind);
    }
    /** @type {?string} */
    const kind = kinds[0] || null;

    /** @type {!Set<string>} */
    const roles = new Set();
    if (audio) {
      for (const role of audio.roles) {
        roles.add(role);
      }
    }
    if (video) {
      for (const role of video.roles) {
        roles.add(role);
      }
    }

    /** @type {shaka.extern.Track} */
    const track = {
      id: variant.id,
      active: false,
      type: 'variant',
      bandwidth: variant.bandwidth,
      language: variant.language,
      label: null,
      videoLabel: null,
      kind: kind,
      width: null,
      height: null,
      frameRate: null,
      pixelAspectRatio: null,
      hdr: null,
      colorGamut: null,
      videoLayout: null,
      mimeType: mimeType,
      audioMimeType: audioMimeType,
      videoMimeType: videoMimeType,
      codecs: '',
      audioCodec: audioCodec,
      videoCodec: videoCodec,
      primary: variant.primary,
      roles: Array.from(roles),
      audioRoles: null,
      videoRoles: null,
      forced: false,
      videoId: null,
      audioId: null,
      audioGroupId: audioGroupId,
      channelsCount: null,
      audioSamplingRate: null,
      spatialAudio: false,
      tilesLayout: null,
      audioBandwidth: null,
      videoBandwidth: null,
      originalVideoId: null,
      originalAudioId: null,
      originalTextId: null,
      originalImageId: null,
      accessibilityPurpose: null,
      originalLanguage: null,
    };

    if (video) {
      track.videoId = video.id;
      track.originalVideoId = video.originalId;
      track.width = video.width || null;
      track.height = video.height || null;
      track.frameRate = video.frameRate || null;
      track.pixelAspectRatio = video.pixelAspectRatio || null;
      track.videoBandwidth = video.bandwidth || null;
      track.hdr = video.hdr || null;
      track.colorGamut = video.colorGamut || null;
      track.videoLayout = video.videoLayout || null;
      track.videoRoles = video.roles;
      track.videoLabel = video.label;

      const dependencyStream = video.dependencyStream;
      if (dependencyStream) {
        track.width = dependencyStream.width || track.width;
        track.height = dependencyStream.height || track.height;
        track.videoCodec = dependencyStream.codecs || track.videoCodec;
        if (track.videoBandwidth && dependencyStream.bandwidth) {
          track.videoBandwidth += dependencyStream.bandwidth;
        }
      }

      if (videoCodec.includes(',')) {
        track.channelsCount = video.channelsCount;
        track.audioSamplingRate = video.audioSamplingRate;
        track.spatialAudio = video.spatialAudio;
        track.originalLanguage = video.originalLanguage;
        track.audioMimeType = videoMimeType;
        const allCodecs = videoCodec.split(',');
        try {
          track.videoCodec = ManifestParserUtils.guessCodecs(
              ContentType.VIDEO, allCodecs);
          track.audioCodec = ManifestParserUtils.guessCodecs(
              ContentType.AUDIO, allCodecs);
        } catch (e) {
          // Ignore this error.
        }
      }
    }

    if (audio) {
      track.audioId = audio.id;
      track.originalAudioId = audio.originalId;
      track.channelsCount = audio.channelsCount;
      track.audioSamplingRate = audio.audioSamplingRate;
      track.audioBandwidth = audio.bandwidth || null;
      track.spatialAudio = audio.spatialAudio;
      track.label = audio.label;
      track.audioRoles = audio.roles;
      track.accessibilityPurpose = audio.accessibilityPurpose;
      track.originalLanguage = audio.originalLanguage;

      const dependencyStream = audio.dependencyStream;
      if (dependencyStream) {
        track.audioCodec = dependencyStream.codecs || track.audioCodec;
        if (track.audioBandwidth && dependencyStream.bandwidth) {
          track.audioBandwidth += dependencyStream.bandwidth;
        }
      }
    }

    if (video && !track.videoBandwidth) {
      if (audio) {
        if (track.audioBandwidth) {
          track.videoBandwidth = track.bandwidth - track.audioBandwidth;
        }
      } else {
        track.videoBandwidth = track.bandwidth;
      }
    }

    if (audio && !track.audioBandwidth) {
      if (video) {
        if (track.videoBandwidth) {
          track.audioBandwidth = track.bandwidth - track.videoBandwidth;
        }
      } else {
        track.audioBandwidth = track.bandwidth;
      }
    }

    /** @type {!Array<string>} */
    const codecs = [];
    if (track.videoCodec) {
      codecs.push(track.videoCodec);
    }
    if (track.audioCodec) {
      codecs.push(track.audioCodec);
    }
    track.codecs = codecs.join(', ');

    return track;
  }


  /**
   * @param {shaka.extern.Stream} stream
   * @return {shaka.extern.TextTrack}
   */
  static textStreamToTrack(stream) {
    const ContentType = shaka.util.ManifestParserUtils.ContentType;

    /** @type {shaka.extern.TextTrack} */
    const track = {
      id: stream.id,
      active: false,
      type: ContentType.TEXT,
      bandwidth: stream.bandwidth || 0,
      language: stream.language,
      label: stream.label,
      kind: stream.kind || null,
      mimeType: stream.mimeType,
      codecs: stream.codecs || null,
      primary: stream.primary,
      roles: stream.roles,
      accessibilityPurpose: stream.accessibilityPurpose,
      forced: stream.forced,
      originalTextId: stream.originalId,
      originalLanguage: stream.originalLanguage,
    };

    return track;
  }


  /**
   * @param {shaka.extern.Stream} stream
   * @return {shaka.extern.ImageTrack}
   */
  static imageStreamToTrack(stream) {
    const ContentType = shaka.util.ManifestParserUtils.ContentType;

    let width = stream.width || null;
    let height = stream.height || null;

    // The stream width and height represent the size of the entire thumbnail
    // sheet, so divide by the layout.
    let reference = null;
    // Note: segmentIndex is built by default for HLS, but not for DASH, but
    // in DASH this information comes at the stream level and not at the
    // segment level.
    if (stream.segmentIndex) {
      reference = stream.segmentIndex.earliestReference();
    }
    let layout = stream.tilesLayout;
    if (reference) {
      layout = reference.getTilesLayout() || layout;
    }
    if (layout && width != null) {
      width /= Number(layout.split('x')[0]);
    }
    if (layout && height != null) {
      height /= Number(layout.split('x')[1]);
    }
    // TODO: What happens if there are multiple grids, with different
    // layout sizes, inside this image stream?

    /** @type {shaka.extern.ImageTrack} */
    const track = {
      id: stream.id,
      type: ContentType.IMAGE,
      bandwidth: stream.bandwidth || 0,
      width,
      height,
      mimeType: stream.mimeType,
      codecs: stream.codecs || null,
      tilesLayout: layout || null,
      originalImageId: stream.originalId,
    };

    return track;
  }


  /**
   * Generate and return an ID for this track, since the ID field is optional.
   *
   * @param {TextTrack|AudioTrack|VideoTrack} html5Track
   * @return {number} The generated ID.
   */
  static html5TrackId(html5Track) {
    if (!html5Track['__shaka_id']) {
      html5Track['__shaka_id'] = shaka.util.StreamUtils.nextTrackId_++;
    }
    return html5Track['__shaka_id'];
  }


  /**
   * @param {TextTrack} textTrack
   * @return {shaka.extern.TextTrack}
   */
  static html5TextTrackToTrack(textTrack) {
    const ContentType = shaka.util.ManifestParserUtils.ContentType;
    /** @type {shaka.extern.TextTrack} */
    const track = {
      id: shaka.util.StreamUtils.html5TrackId(textTrack),
      active: textTrack.mode != 'disabled',
      type: ContentType.TEXT,
      bandwidth: 0,
      language: shaka.util.LanguageUtils.normalize(textTrack.language || 'und'),
      label: textTrack.label,
      kind: textTrack.kind,
      mimeType: null,
      codecs: null,
      primary: false,
      roles: [],
      accessibilityPurpose: null,
      forced: textTrack.kind == 'forced',
      originalTextId: textTrack.id,
      originalLanguage: textTrack.language,
    };
    if (textTrack.kind == 'captions') {
      // See: https://github.com/shaka-project/shaka-player/issues/6233
      track.mimeType = 'unknown';
    }
    if (textTrack.kind == 'subtitles') {
      track.mimeType = 'text/vtt';
    }
    if (textTrack.kind) {
      track.roles = [textTrack.kind];
    }

    return track;
  }


  /**
   * @param {AudioTrack} audioTrack
   * @return {shaka.extern.AudioTrack}
   */
  static html5AudioTrackToTrack(audioTrack) {
    const language = audioTrack.language;

    /** @type {shaka.extern.AudioTrack} */
    const track = {
      active: audioTrack.enabled,
      language: shaka.util.LanguageUtils.normalize(language || 'und'),
      label: audioTrack.label,
      mimeType: null,
      codecs: null,
      primary: audioTrack.kind == 'main',
      roles: [],
      accessibilityPurpose: null,
      channelsCount: null,
      audioSamplingRate: null,
      spatialAudio: false,
      originalLanguage: language,
    };

    if (audioTrack.kind) {
      track.roles.push(audioTrack.kind);
    }

    if (audioTrack.configuration) {
      if (audioTrack.configuration.codec) {
        track.codecs = audioTrack.configuration.codec;
      }
      if (audioTrack.configuration.sampleRate) {
        track.audioSamplingRate = audioTrack.configuration.sampleRate;
      }
      if (audioTrack.configuration.numberOfChannels) {
        track.channelsCount = audioTrack.configuration.numberOfChannels;
      }
    }

    return track;
  }


  /**
   * @param {?AudioTrack} audioTrack
   * @param {?VideoTrack} videoTrack
   * @return {shaka.extern.Track}
   */
  static html5TrackToShakaTrack(audioTrack, videoTrack) {
    goog.asserts.assert(audioTrack || videoTrack,
        'There must be at least audioTrack or videoTrack.');

    const LanguageUtils = shaka.util.LanguageUtils;

    const language = audioTrack ? audioTrack.language : null;

    /** @type {shaka.extern.Track} */
    const track = {
      id: shaka.util.StreamUtils.html5TrackId(audioTrack || videoTrack),
      active: audioTrack ? audioTrack.enabled : videoTrack.selected,
      type: 'variant',
      bandwidth: 0,
      language: LanguageUtils.normalize(language || 'und'),
      label: audioTrack ? audioTrack.label : null,
      videoLabel: null,
      kind: audioTrack ? audioTrack.kind : null,
      width: null,
      height: null,
      frameRate: null,
      pixelAspectRatio: null,
      hdr: null,
      colorGamut: null,
      videoLayout: null,
      mimeType: null,
      audioMimeType: null,
      videoMimeType: null,
      codecs: null,
      audioCodec: null,
      videoCodec: null,
      primary: audioTrack ? audioTrack.kind == 'main' : false,
      roles: [],
      forced: false,
      audioRoles: null,
      videoRoles: null,
      videoId: null,
      audioId: null,
      audioGroupId: null,
      channelsCount: null,
      audioSamplingRate: null,
      spatialAudio: false,
      tilesLayout: null,
      audioBandwidth: null,
      videoBandwidth: null,
      originalVideoId: videoTrack ? videoTrack.id : null,
      originalAudioId: audioTrack ? audioTrack.id : null,
      originalTextId: null,
      originalImageId: null,
      accessibilityPurpose: null,
      originalLanguage: language,
    };

    if (audioTrack && audioTrack.kind) {
      track.roles = [audioTrack.kind];
      track.audioRoles = [audioTrack.kind];
    }

    if (audioTrack && audioTrack.configuration) {
      if (audioTrack.configuration.codec) {
        track.audioCodec = audioTrack.configuration.codec;
        track.codecs = track.audioCodec;
      }
      if (audioTrack.configuration.bitrate) {
        track.audioBandwidth = audioTrack.configuration.bitrate;
        track.bandwidth += track.audioBandwidth;
      }
      if (audioTrack.configuration.sampleRate) {
        track.audioSamplingRate = audioTrack.configuration.sampleRate;
      }
      if (audioTrack.configuration.numberOfChannels) {
        track.channelsCount = audioTrack.configuration.numberOfChannels;
      }
    }

    if (videoTrack && videoTrack.configuration) {
      if (videoTrack.configuration.codec) {
        track.videoCodec = videoTrack.configuration.codec;
        if (track.codecs) {
          track.codecs += ',' + track.videoCodec;
        } else {
          track.codecs = track.videoCodec;
        }
      }
      if (videoTrack.configuration.bitrate) {
        track.videoBandwidth = videoTrack.configuration.bitrate;
        track.bandwidth += track.videoBandwidth;
      }
      if (videoTrack.configuration.framerate) {
        track.frameRate = videoTrack.configuration.framerate;
      }
      if (videoTrack.configuration.width) {
        track.width = videoTrack.configuration.width;
      }
      if (videoTrack.configuration.height) {
        track.height = videoTrack.configuration.height;
      }
      if (videoTrack.configuration.colorSpace &&
          videoTrack.configuration.colorSpace.transfer) {
        switch (videoTrack.configuration.colorSpace.transfer) {
          case 'pq':
            track.hdr = 'PQ';
            break;
          case 'hlg':
            track.hdr = 'HLG';
            break;
          case 'bt709':
            track.hdr = 'SDR';
            break;
        }
      }
    }

    return track;
  }


  /**
   * Determines if the given variant is playable.
   * @param {!shaka.extern.Variant} variant
   * @return {boolean}
   */
  static isPlayable(variant) {
    return variant.allowedByApplication &&
        variant.allowedByKeySystem &&
        variant.disabledUntilTime == 0;
  }


  /**
   * Filters out unplayable variants.
   * @param {!Array<!shaka.extern.Variant>} variants
   * @return {!Array<!shaka.extern.Variant>}
   */
  static getPlayableVariants(variants) {
    return variants.filter((variant) => {
      return shaka.util.StreamUtils.isPlayable(variant);
    });
  }


  /**
   * Chooses streams according to the given config.
   * Works both for Stream and Track types due to their similarities.
   *
   * @param {!Array<!shaka.extern.Stream>|!Array<!shaka.extern.Track>} streams
   * @param {string} preferredLanguage
   * @param {string} preferredRole
   * @param {boolean} preferredForced
   * @return {!Array<!shaka.extern.Stream>|!Array<!shaka.extern.Track>}
   */
  static filterStreamsByLanguageAndRole(
      streams, preferredLanguage, preferredRole, preferredForced) {
    const LanguageUtils = shaka.util.LanguageUtils;

    /** @type {!Array<!shaka.extern.Stream>|!Array<!shaka.extern.Track>} */
    let chosen = streams;

    // Start with the set of primary streams.
    /** @type {!Array<!shaka.extern.Stream>|!Array<!shaka.extern.Track>} */
    const primary = streams.filter((stream) => {
      return stream.primary;
    });

    if (primary.length) {
      chosen = primary;
    }

    // Now reduce the set to one language.  This covers both arbitrary language
    // choice and the reduction of the "primary" stream set to one language.
    const firstLanguage = chosen.length ? chosen[0].language : '';
    chosen = chosen.filter((stream) => {
      return stream.language == firstLanguage;
    });

    // Find the streams that best match our language preference. This will
    // override previous selections.
    if (preferredLanguage) {
      const closestLocale = LanguageUtils.findClosestLocale(
          LanguageUtils.normalize(preferredLanguage),
          streams.map((stream) => stream.language));

      // Only replace |chosen| if we found a locale that is close to our
      // preference.
      if (closestLocale) {
        chosen = streams.filter((stream) => {
          const locale = LanguageUtils.normalize(stream.language);
          return locale == closestLocale;
        });
      }
    }

    // Filter by forced preference
    chosen = chosen.filter((stream) => {
      return stream.forced == preferredForced;
    });

    // Now refine the choice based on role preference.
    if (preferredRole) {
      const roleMatches = shaka.util.StreamUtils.filterStreamsByRole_(
          chosen, preferredRole);
      if (roleMatches.length) {
        return roleMatches;
      } else {
        shaka.log.warning('No exact match for the text role could be found.');
      }
    } else {
      // Prefer text streams with no roles, if they exist.
      const noRoleMatches = chosen.filter((stream) => {
        return stream.roles.length == 0;
      });
      if (noRoleMatches.length) {
        return noRoleMatches;
      }
    }

    // Either there was no role preference, or it could not be satisfied.
    // Choose an arbitrary role, if there are any, and filter out any other
    // roles. This ensures we never adapt between roles.

    const allRoles = chosen.map((stream) => {
      return stream.roles;
    }).reduce(shaka.util.Functional.collapseArrays, []);

    if (!allRoles.length) {
      return chosen;
    }
    return shaka.util.StreamUtils.filterStreamsByRole_(chosen, allRoles[0]);
  }


  /**
   * Filter Streams by role.
   * Works both for Stream and Track types due to their similarities.
   *
   * @param {!Array<!shaka.extern.Stream>|!Array<!shaka.extern.Track>} streams
   * @param {string} preferredRole
   * @return {!Array<!shaka.extern.Stream>|!Array<!shaka.extern.Track>}
   * @private
   */
  static filterStreamsByRole_(streams, preferredRole) {
    return streams.filter((stream) => {
      return stream.roles.includes(preferredRole);
    });
  }


  /**
   * Checks if the given stream is an audio stream.
   *
   * @param {shaka.extern.Stream} stream
   * @return {boolean}
   */
  static isAudio(stream) {
    const ContentType = shaka.util.ManifestParserUtils.ContentType;
    return stream.type == ContentType.AUDIO;
  }


  /**
   * Checks if the given stream is a video stream.
   *
   * @param {shaka.extern.Stream} stream
   * @return {boolean}
   */
  static isVideo(stream) {
    const ContentType = shaka.util.ManifestParserUtils.ContentType;
    return stream.type == ContentType.VIDEO;
  }


  /**
   * Get all non-null streams in the variant as an array.
   *
   * @param {shaka.extern.Variant} variant
   * @return {!Array<shaka.extern.Stream>}
   */
  static getVariantStreams(variant) {
    const streams = [];

    if (variant.audio) {
      streams.push(variant.audio);
    }
    if (variant.video) {
      streams.push(variant.video);
    }

    return streams;
  }


  /**
   * Indicates if some of the variant's streams are fastSwitching.
   *
   * @param {shaka.extern.Variant} variant
   * @return {boolean}
   */
  static isFastSwitching(variant) {
    if (variant.audio && variant.audio.fastSwitching) {
      return true;
    }
    if (variant.video && variant.video.fastSwitching) {
      return true;
    }
    return false;
  }


  /**
   * Set the best iframe stream to the original stream.
   *
   * @param {!shaka.extern.Stream} stream
   * @param {!Array<!shaka.extern.Stream>} iFrameStreams
   */
  static setBetterIFrameStream(stream, iFrameStreams) {
    if (!iFrameStreams.length) {
      return;
    }
    const validStreams = iFrameStreams.filter((iFrameStream) =>
      shaka.util.MimeUtils.getNormalizedCodec(stream.codecs) ==
      shaka.util.MimeUtils.getNormalizedCodec(iFrameStream.codecs))
        .sort((a, b) => {
          if (!a.bandwidth || !b.bandwidth || a.bandwidth == b.bandwidth) {
            return (a.width || 0) - (b.width || 0);
          }
          return a.bandwidth - b.bandwidth;
        });
    stream.trickModeVideo = validStreams[0];
    if (validStreams.length > 1) {
      const sameResolutionStream = validStreams.find((iFrameStream) =>
        stream.width == iFrameStream.width &&
        stream.height == iFrameStream.height);
      if (sameResolutionStream) {
        stream.trickModeVideo = sameResolutionStream;
      }
    }
  }


  /**
   * Returns a string of a variant, with the attribute values of its audio
   * and/or video streams for log printing.
   * @param {shaka.extern.Variant} variant
   * @return {string}
   * @private
   */
  static getVariantSummaryString_(variant) {
    const summaries = [];
    if (variant.audio) {
      summaries.push(shaka.util.StreamUtils.getStreamSummaryString_(
          variant.audio));
    }
    if (variant.video) {
      summaries.push(shaka.util.StreamUtils.getStreamSummaryString_(
          variant.video));
    }
    return summaries.join(', ');
  }

  /**
   * Returns a string of an audio or video stream for log printing.
   * @param {shaka.extern.Stream} stream
   * @return {string}
   * @private
   */
  static getStreamSummaryString_(stream) {
    // Accepted parameters for Chromecast can be found (internally) at
    // go/cast-mime-params

    if (shaka.util.StreamUtils.isAudio(stream)) {
      return 'type=audio' +
             ' codecs=' + stream.codecs +
             ' bandwidth='+ stream.bandwidth +
             ' channelsCount=' + stream.channelsCount +
             ' audioSamplingRate=' + stream.audioSamplingRate;
    }

    if (shaka.util.StreamUtils.isVideo(stream)) {
      return 'type=video' +
             ' codecs=' + stream.codecs +
             ' bandwidth=' + stream.bandwidth +
             ' frameRate=' + stream.frameRate +
             ' width=' + stream.width +
             ' height=' + stream.height;
    }

    return 'unexpected stream type';
  }


  /**
   * Clears underlying decoding config cache.
   */
  static clearDecodingConfigCache() {
    shaka.util.StreamUtils.decodingConfigCache_.clear();
  }


  /**
   * Check if we should show text on screen automatically.
   *
   * @param {?shaka.extern.Stream} audioStream
   * @param {shaka.extern.Stream} textStream
   * @param {!shaka.extern.PlayerConfiguration} config
   * @return {boolean}
   */
  static shouldInitiallyShowText(audioStream, textStream, config) {
    const AutoShowText = shaka.config.AutoShowText;

    if (config.autoShowText == AutoShowText.NEVER) {
      return false;
    }
    if (config.autoShowText == AutoShowText.ALWAYS) {
      return true;
    }

    const LanguageUtils = shaka.util.LanguageUtils;

    /** @type {string} */
    const preferredTextLocale =
        LanguageUtils.normalize(config.preferredTextLanguage);
    /** @type {string} */
    const textLocale = LanguageUtils.normalize(textStream.language);

    if (config.autoShowText == AutoShowText.IF_PREFERRED_TEXT_LANGUAGE) {
      // Only the text language match matters.
      return LanguageUtils.areLanguageCompatible(
          textLocale,
          preferredTextLocale);
    }

    if (config.autoShowText == AutoShowText.IF_SUBTITLES_MAY_BE_NEEDED) {
      if (!audioStream) {
        return false;
      }
      /* The text should automatically be shown if the text is
       * language-compatible with the user's text language preference, but not
       * compatible with the audio.  These are cases where we deduce that
       * subtitles may be needed.
       *
       * For example:
       *   preferred | chosen | chosen |
       *   text      | text   | audio  | show
       *   -----------------------------------
       *   en-CA     | en     | jp     | true
       *   en        | en-US  | fr     | true
       *   fr-CA     | en-US  | jp     | false
       *   en-CA     | en-US  | en-US  | false
       *
       */
      /** @type {string} */
      const audioLocale = LanguageUtils.normalize(audioStream.language);

      return (
        LanguageUtils.areLanguageCompatible(textLocale, preferredTextLocale) &&
        !LanguageUtils.areLanguageCompatible(audioLocale, textLocale));
    }

    shaka.log.alwaysWarn('Invalid autoShowText setting!');
    return false;
  }

  /**
   * @param {!Array<string>} mimeTypes
   * @return {!shaka.extern.Variant}
   */
  static createEmptyVariant(mimeTypes) {
    const ContentType = shaka.util.ManifestParserUtils.ContentType;

    /** @type {shaka.extern.Variant} */
    const variant = {
      id: 0,
      language: 'und',
      disabledUntilTime: 0,
      primary: false,
      audio: null,
      video: null,
      bandwidth: 100,
      allowedByApplication: true,
      allowedByKeySystem: true,
      decodingInfos: [],
    };
    for (const mimeType of mimeTypes) {
      const stream = {
        id: 0,
        originalId: null,
        groupId: null,
        createSegmentIndex: () => Promise.resolve(),
        segmentIndex: null,
        mimeType: mimeType ? shaka.util.MimeUtils.getBasicType(mimeType) : '',
        codecs: mimeType ? shaka.util.MimeUtils.getCodecs(mimeType) : '',
        encrypted: true,
        drmInfos: [],
        keyIds: new Set(),
        language: 'und',
        originalLanguage: null,
        label: null,
        type: ContentType.VIDEO,
        primary: false,
        trickModeVideo: null,
        dependencyStream: null,
        emsgSchemeIdUris: null,
        roles: [],
        forced: false,
        channelsCount: null,
        audioSamplingRate: null,
        spatialAudio: false,
        closedCaptions: null,
        accessibilityPurpose: null,
        external: false,
        fastSwitching: false,
        fullMimeTypes: new Set(),
        isAudioMuxedInVideo: false,
        baseOriginalId: null,
      };
      stream.fullMimeTypes.add(shaka.util.MimeUtils.getFullType(
          stream.mimeType, stream.codecs));
      if (mimeType.startsWith('audio/')) {
        stream.type = ContentType.AUDIO;
        variant.audio = stream;
      } else {
        variant.video = stream;
      }
    }

    return variant;
  }
};


/**
 * A cache of results from mediaCapabilities.decodingInfo, indexed by the
 * (stringified) decodingConfig.
 *
 * @type {Map<string, !MediaCapabilitiesDecodingInfo>}
 * @private
 */
shaka.util.StreamUtils.decodingConfigCache_ = new Map();


/** @private {number} */
shaka.util.StreamUtils.nextTrackId_ = 0;

/**
 * @enum {string}
 */
shaka.util.StreamUtils.DecodingAttributes = {
  SMOOTH: 'smooth',
  POWER: 'powerEfficient',
};

/**
 * @private {!Map<string, boolean>}
 */
shaka.util.StreamUtils.supportedImageMimeTypes_ = new Map()
    .set('image/svg+xml', true)
    .set('image/png', true)
    .set('image/jpeg', true)
    .set('image/jpg', true);

/**
 * @const {string}
 * @private
 */
// cspell: disable-next-line
shaka.util.StreamUtils.minWebPImage_ = 'data:image/webp;base64,UklGRjoAAABXRU' +
    'JQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwY' +
    'AAA';

/**
 * @const {string}
 * @private
 */
// cspell: disable-next-line
shaka.util.StreamUtils.minAvifImage_ = 'data:image/avif;base64,AAAAIGZ0eXBhdm' +
    'lmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljd' +
    'AAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEA' +
    'AAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAA' +
    'AamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAA' +
    'xhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAA' +
    'CVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A=';

/**
 * @const {!Map<string, string>}
 * @private
 */
shaka.util.StreamUtils.minImage_ = new Map()
    .set('image/webp', shaka.util.StreamUtils.minWebPImage_)
    .set('image/avif', shaka.util.StreamUtils.minAvifImage_);
