Source: lib/util/player_configuration.js

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

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

goog.require('goog.asserts');
goog.require('shaka.abr.SimpleAbrManager');
goog.require('shaka.config.AutoShowText');
goog.require('shaka.config.CodecSwitchingStrategy');
goog.require('shaka.log');
goog.require('shaka.media.Capabilities');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.ConfigUtils');
goog.require('shaka.util.FairPlayUtils');
goog.require('shaka.util.LanguageUtils');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.Platform');


/**
 * @final
 * @export
 */
shaka.util.PlayerConfiguration = class {
  /**
   * @return {shaka.extern.PlayerConfiguration}
   * @export
   */
  static createDefault() {
    // This is a relatively safe default in the absence of clues from the
    // browser.  For slower connections, the default estimate may be too high.
    const bandwidthEstimate = 1e6; // 1Mbps
    const minBytes = 16e3;

    let abrMaxHeight = Infinity;

    // Some browsers implement the Network Information API, which allows
    // retrieving information about a user's network connection.
    if (navigator.connection) {
      // If the user has checked a box in the browser to ask it to use less
      // data, the browser will expose this intent via connection.saveData.
      // When that is true, we will default the max ABR height to 360p. Apps
      // can override this if they wish.
      //
      // The decision to use 360p was somewhat arbitrary. We needed a default
      // limit, and rather than restrict to a certain bandwidth, we decided to
      // restrict resolution. This will implicitly restrict bandwidth and
      // therefore save data. We (Shaka+Chrome) judged that:
      //   - HD would be inappropriate
      //   - If a user is asking their browser to save data, 360p it reasonable
      //   - 360p would not look terrible on small mobile device screen
      // We also found that:
      //   - YouTube's website on mobile defaults to 360p (as of 2018)
      //   - iPhone 6, in portrait mode, has a physical resolution big enough
      //     for 360p widescreen, but a little smaller than 480p widescreen
      //     (https://apple.co/2yze4es)
      // If the content's lowest resolution is above 360p, AbrManager will use
      // the lowest resolution.
      if (navigator.connection.saveData) {
        abrMaxHeight = 360;
      }
    }

    const drm = {
      retryParameters: shaka.net.NetworkingEngine.defaultRetryParameters(),
      // These will all be verified by special cases in mergeConfigObjects_():
      servers: {},    // key is arbitrary key system ID, value must be string
      clearKeys: {},  // key is arbitrary key system ID, value must be string
      advanced: {},    // key is arbitrary key system ID, value is a record type
      delayLicenseRequestUntilPlayed: false,
      persistentSessionOnlinePlayback: false,
      persistentSessionsMetadata: [],
      initDataTransform: (initData, initDataType, drmInfo) => {
        if (shaka.util.Platform.isMediaKeysPolyfilled() &&
            initDataType == 'skd') {
          const cert = drmInfo.serverCertificate;
          const contentId =
              shaka.util.FairPlayUtils.defaultGetContentId(initData);
          initData = shaka.util.FairPlayUtils.initDataTransform(
              initData, contentId, cert);
        }
        return initData;
      },
      logLicenseExchange: false,
      updateExpirationTime: 1,
      preferredKeySystems: [],
      keySystemsMapping: {},
      // The Xbox One browser does not detect DRM key changes signalled by a
      // change in the PSSH in media segments. We need to parse PSSH from media
      // segments to detect key changes.
      parseInbandPsshEnabled: shaka.util.Platform.isXboxOne(),
      minHdcpVersion: '',
      ignoreDuplicateInitData: !shaka.util.Platform.isTizen2(),
    };

    // The Xbox One and PS4 only support the Playready DRM, so they should
    // prefer that key system by default to improve startup performance.
    if (shaka.util.Platform.isXboxOne() ||
        shaka.util.Platform.isPS4()) {
      drm.preferredKeySystems.push('com.microsoft.playready');
    }

    let codecSwitchingStrategy = shaka.config.CodecSwitchingStrategy.RELOAD;
    let multiTypeVariantsAllowed = false;
    if (shaka.media.Capabilities.isChangeTypeSupported() &&
        shaka.util.Platform.supportsSmoothCodecSwitching()) {
      codecSwitchingStrategy = shaka.config.CodecSwitchingStrategy.SMOOTH;
      multiTypeVariantsAllowed = true;
    }

    const manifest = {
      retryParameters: shaka.net.NetworkingEngine.defaultRetryParameters(),
      availabilityWindowOverride: NaN,
      disableAudio: false,
      disableVideo: false,
      disableText: false,
      disableThumbnails: false,
      defaultPresentationDelay: 0,
      segmentRelativeVttTiming: false,
      raiseFatalErrorOnManifestUpdateRequestFailure: false,
      continueLoadingWhenPaused: true,
      dash: {
        clockSyncUri: '',
        ignoreDrmInfo: false,
        disableXlinkProcessing: false,
        xlinkFailGracefully: false,
        ignoreMinBufferTime: false,
        autoCorrectDrift: true,
        initialSegmentLimit: 1000,
        ignoreSuggestedPresentationDelay: false,
        ignoreEmptyAdaptationSet: false,
        ignoreMaxSegmentDuration: false,
        keySystemsByURI: {
          'urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b':
            'org.w3.clearkey',
          'urn:uuid:e2719d58-a985-b3c9-781a-b030af78d30e':
            'org.w3.clearkey',
          'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed':
            'com.widevine.alpha',
          'urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95':
            'com.microsoft.playready',
          'urn:uuid:79f0049a-4098-8642-ab92-e65be0885f95':
            'com.microsoft.playready',
        },
        manifestPreprocessor:
            shaka.util.PlayerConfiguration.defaultManifestPreprocessor,
        manifestPreprocessorTXml:
            shaka.util.PlayerConfiguration.defaultManifestPreprocessorTXml,
        sequenceMode: false,
        enableAudioGroups: false,
        multiTypeVariantsAllowed,
        useStreamOnceInPeriodFlattening: false,
        updatePeriod: -1,
        enableFastSwitching: true,
      },
      hls: {
        ignoreTextStreamFailures: false,
        ignoreImageStreamFailures: false,
        defaultAudioCodec: 'mp4a.40.2',
        defaultVideoCodec: 'avc1.42E01E',
        ignoreManifestProgramDateTime: false,
        ignoreManifestProgramDateTimeForTypes: [],
        mediaPlaylistFullMimeType:
            'video/mp2t; codecs="avc1.42E01E, mp4a.40.2"',
        liveSegmentsDelay: 3,
        sequenceMode: shaka.util.Platform.supportsSequenceMode(),
        ignoreManifestTimestampsInSegmentsMode: false,
        disableCodecGuessing: false,
        disableClosedCaptionsDetection: false,
        allowLowLatencyByteRangeOptimization: true,
      },
      mss: {
        manifestPreprocessor:
            shaka.util.PlayerConfiguration.defaultManifestPreprocessor,
        manifestPreprocessorTXml:
            shaka.util.PlayerConfiguration.defaultManifestPreprocessorTXml,
        sequenceMode: false,
        keySystemsBySystemId: {
          '9a04f079-9840-4286-ab92-e65be0885f95':
            'com.microsoft.playready',
          '79f0049a-4098-8642-ab92-e65be0885f95':
            'com.microsoft.playready',
        },
      },
    };

    const streaming = {
      retryParameters: shaka.net.NetworkingEngine.defaultRetryParameters(),
      // Need some operation in the callback or else closure may remove calls
      // to the function as it would be a no-op.  The operation can't just be a
      // log message, because those are stripped in the compiled build.
      failureCallback: (error) => {
        shaka.log.error('Unhandled streaming error', error);
        return shaka.util.ConfigUtils.referenceParametersAndReturn(
            [error],
            undefined);
      },
      // When low latency streaming is enabled, rebufferingGoal will default to
      // 0.01 if not specified.
      rebufferingGoal: 2,
      bufferingGoal: 10,
      bufferBehind: 30,
      evictionGoal: 1,
      ignoreTextStreamFailures: false,
      alwaysStreamText: false,
      startAtSegmentBoundary: false,
      gapDetectionThreshold: 0.5,
      gapPadding: 0.01,
      gapJumpTimerTime: 0.25 /* seconds */,
      durationBackoff: 1,
      // Offset by 5 seconds since Chromecast takes a few seconds to start
      // playing after a seek, even when buffered.
      safeSeekOffset: 5,
      stallEnabled: true,
      stallThreshold: 1 /* seconds */,
      stallSkip: 0.1 /* seconds */,
      useNativeHlsForFairPlay: true,
      // If we are within 2 seconds of the start of a live segment, fetch the
      // previous one.  This allows for segment drift, but won't download an
      // extra segment if we aren't close to the start.
      // When low latency streaming is enabled,  inaccurateManifestTolerance
      // will default to 0 if not specified.
      inaccurateManifestTolerance: 2,
      lowLatencyMode: false,
      autoLowLatencyMode: false,
      forceHTTP: false,
      forceHTTPS: false,
      minBytesForProgressEvents: minBytes,
      preferNativeHls: false,
      updateIntervalSeconds: 1,
      dispatchAllEmsgBoxes: false,
      observeQualityChanges: false,
      maxDisabledTime: 30,
      parsePrftBox: false,
      // When low latency streaming is enabled, segmentPrefetchLimit will
      // default to 2 if not specified.
      segmentPrefetchLimit: 0,
      prefetchAudioLanguages: [],
      disableAudioPrefetch: false,
      disableTextPrefetch: false,
      disableVideoPrefetch: false,
      liveSync: {
        enabled: false,
        targetLatency: 0.5,
        targetLatencyTolerance: 0.5,
        maxPlaybackRate: 1.1,
        minPlaybackRate: 0.95,
        panicMode: false,
        panicThreshold: 60,
        dynamicTargetLatency: {
          enabled: false,
          stabilityThreshold: 60,
          rebufferIncrement: 0.5,
          maxAttempts: 10,
          maxLatency: 4,
          minLatency: 1,
        },
      },
      allowMediaSourceRecoveries: true,
      minTimeBetweenRecoveries: 5,
      vodDynamicPlaybackRate: false,
      vodDynamicPlaybackRateLowBufferRate: 0.95,
      vodDynamicPlaybackRateBufferRatio: 0.5,
      infiniteLiveStreamDuration: false,
      preloadNextUrlWindow: 30,
      loadTimeout: 30,
      clearDecodingCache: shaka.util.Platform.isPS4() ||
        shaka.util.Platform.isPS5(),
      dontChooseCodecs: false,
      shouldFixTimestampOffset: shaka.util.Platform.isWebOS() ||
        shaka.util.Platform.isTizen(),
    };

    // WebOS, Tizen, Chromecast and Hisense have long hardware pipelines
    // that respond slowly to seeking.
    // Therefore we should not seek when we detect a stall
    // on one of these platforms.  Instead, default stallSkip to 0 to force the
    // stall detector to pause and play instead.
    if (shaka.util.Platform.isWebOS() ||
        shaka.util.Platform.isTizen() ||
        shaka.util.Platform.isChromecast() ||
        shaka.util.Platform.isHisense()) {
      streaming.stallSkip = 0;
    }

    if (shaka.util.Platform.isTizen()) {
      streaming.gapPadding = 2;
    }

    const offline = {
      // We need to set this to a throw-away implementation for now as our
      // default implementation will need to reference other fields in the
      // config. We will set it to our intended implementation after we have
      // the top-level object created.
      // eslint-disable-next-line require-await
      trackSelectionCallback: async (tracks) => tracks,

      downloadSizeCallback: async (sizeEstimate) => {
        if (navigator.storage && navigator.storage.estimate) {
          const estimate = await navigator.storage.estimate();
          // Limit to 95% of quota.
          return estimate.usage + sizeEstimate < estimate.quota * 0.95;
        } else {
          return true;
        }
      },

      // Need some operation in the callback or else closure may remove calls
      // to the function as it would be a no-op.  The operation can't just be a
      // log message, because those are stripped in the compiled build.
      progressCallback: (content, progress) => {
        return shaka.util.ConfigUtils.referenceParametersAndReturn(
            [content, progress],
            undefined);
      },

      // By default we use persistent licenses as forces errors to surface if
      // a platform does not support offline licenses rather than causing
      // unexpected behaviours when someone tries to plays downloaded content
      // without a persistent license.
      usePersistentLicense: true,

      numberOfParallelDownloads: 5,
    };

    const abr = {
      enabled: true,
      useNetworkInformation: true,
      defaultBandwidthEstimate: bandwidthEstimate,
      switchInterval: 8,
      bandwidthUpgradeTarget: 0.85,
      bandwidthDowngradeTarget: 0.95,
      restrictions: {
        minWidth: 0,
        maxWidth: Infinity,
        minHeight: 0,
        maxHeight: abrMaxHeight,
        minPixels: 0,
        maxPixels: Infinity,
        minFrameRate: 0,
        maxFrameRate: Infinity,
        minBandwidth: 0,
        maxBandwidth: Infinity,
        minChannelsCount: 0,
        maxChannelsCount: Infinity,
      },
      advanced: {
        minTotalBytes: 128e3,
        minBytes,
        fastHalfLife: 2,
        slowHalfLife: 5,
      },
      restrictToElementSize: false,
      restrictToScreenSize: false,
      ignoreDevicePixelRatio: false,
      clearBufferSwitch: false,
      safeMarginSwitch: 0,
      cacheLoadThreshold: 20,
      minTimeToSwitch: shaka.util.Platform.isApple() ? 0.5 : 0,
      preferNetworkInformationBandwidth: false,
    };

    const cmcd = {
      enabled: false,
      sessionId: '',
      contentId: '',
      rtpSafetyFactor: 5,
      useHeaders: false,
      includeKeys: [],
    };

    const cmsd = {
      enabled: true,
      applyMaximumSuggestedBitrate: true,
      estimatedThroughputWeightRatio: 0.5,
    };

    const lcevc = {
      enabled: false,
      dynamicPerformanceScaling: true,
      logLevel: 0,
      drawLogo: false,
    };

    const mediaSource = {
      codecSwitchingStrategy: codecSwitchingStrategy,
      addExtraFeaturesToSourceBuffer: (mimeType) => {
        return shaka.util.ConfigUtils.referenceParametersAndReturn(
            [mimeType],
            '');
      },
      forceTransmux: false,
      insertFakeEncryptionInInit: true,
      modifyCueCallback: (cue, uri) => {
        return shaka.util.ConfigUtils.referenceParametersAndReturn(
            [cue, uri],
            undefined);
      },
    };

    let customPlayheadTracker = false;
    let skipPlayDetection = false;
    let supportsMultipleMediaElements = true;
    if (shaka.util.Platform.isSmartTV()) {
      customPlayheadTracker = true;
      skipPlayDetection = true;
      supportsMultipleMediaElements = false;
    }

    const ads = {
      customPlayheadTracker,
      skipPlayDetection,
      supportsMultipleMediaElements,
    };

    const textDisplayer = {
      captionsUpdatePeriod: 0.25,
    };

    const AutoShowText = shaka.config.AutoShowText;

    /** @type {shaka.extern.PlayerConfiguration} */
    const config = {
      drm: drm,
      manifest: manifest,
      streaming: streaming,
      mediaSource: mediaSource,
      offline: offline,
      abrFactory: () => new shaka.abr.SimpleAbrManager(),
      abr: abr,
      autoShowText: AutoShowText.IF_SUBTITLES_MAY_BE_NEEDED,
      preferredAudioLanguage: '',
      preferredAudioLabel: '',
      preferredTextLanguage: '',
      preferredVariantRole: '',
      preferredTextRole: '',
      preferredAudioChannelCount: 2,
      preferredVideoHdrLevel: 'AUTO',
      preferredVideoLayout: '',
      preferredVideoLabel: '',
      preferredVideoCodecs: [],
      preferredAudioCodecs: [],
      preferForcedSubs: false,
      preferSpatialAudio: false,
      preferredDecodingAttributes: [],
      restrictions: {
        minWidth: 0,
        maxWidth: Infinity,
        minHeight: 0,
        maxHeight: Infinity,
        minPixels: 0,
        maxPixels: Infinity,
        minFrameRate: 0,
        maxFrameRate: Infinity,
        minBandwidth: 0,
        maxBandwidth: Infinity,
        minChannelsCount: 0,
        maxChannelsCount: Infinity,
      },
      playRangeStart: 0,
      playRangeEnd: Infinity,
      textDisplayer: textDisplayer,
      textDisplayFactory: () => null,
      cmcd: cmcd,
      cmsd: cmsd,
      lcevc: lcevc,
      ads: ads,
    };

    // Add this callback so that we can reference the preferred audio language
    // through the config object so that if it gets updated, we have the
    // updated value.
    // eslint-disable-next-line require-await
    offline.trackSelectionCallback = async (tracks) => {
      return shaka.util.PlayerConfiguration.defaultTrackSelect(
          tracks, config.preferredAudioLanguage,
          config.preferredVideoHdrLevel);
    };

    return config;
  }

  /**
   * Merges the given configuration changes into the given destination.  This
   * uses the default Player configurations as the template.
   *
   * @param {shaka.extern.PlayerConfiguration} destination
   * @param {!Object} updates
   * @param {shaka.extern.PlayerConfiguration=} template
   * @return {boolean}
   * @export
   */
  static mergeConfigObjects(destination, updates, template) {
    const overrides = {
      '.drm.keySystemsMapping': '',
      '.drm.servers': '',
      '.drm.clearKeys': '',
      '.drm.advanced': {
        distinctiveIdentifierRequired: false,
        persistentStateRequired: false,
        videoRobustness: '',
        audioRobustness: '',
        sessionType: '',
        serverCertificate: new Uint8Array(0),
        serverCertificateUri: '',
        individualizationServer: '',
        headers: {},
      },
    };
    return shaka.util.ConfigUtils.mergeConfigObjects(
        destination, updates,
        template || shaka.util.PlayerConfiguration.createDefault(), overrides,
        '');
  }

  /**
   * @param {!Array.<shaka.extern.Track>} tracks
   * @param {string} preferredAudioLanguage
   * @param {string} preferredVideoHdrLevel
   * @return {!Array.<shaka.extern.Track>}
   */
  static defaultTrackSelect(
      tracks, preferredAudioLanguage, preferredVideoHdrLevel) {
    const ContentType = shaka.util.ManifestParserUtils.ContentType;
    const LanguageUtils = shaka.util.LanguageUtils;

    let hdrLevel = preferredVideoHdrLevel;
    if (hdrLevel == 'AUTO') {
      // Auto detect the ideal HDR level.
      if (window.matchMedia('(color-gamut: p3)').matches) {
        hdrLevel = 'PQ';
      } else {
        hdrLevel = 'SDR';
      }
    }

    /** @type {!Array.<shaka.extern.Track>} */
    const allVariants = tracks.filter((track) => {
      if (track.type != 'variant') {
        return false;
      }
      if (track.hdr && track.hdr != hdrLevel) {
        return false;
      }
      return true;
    });

    /** @type {!Array.<shaka.extern.Track>} */
    let selectedVariants = [];

    // Find the locale that best matches our preferred audio locale.
    const closestLocale = LanguageUtils.findClosestLocale(
        preferredAudioLanguage,
        allVariants.map((variant) => variant.language));
    // If we found a locale that was close to our preference, then only use
    // variants that use that locale.
    if (closestLocale) {
      selectedVariants = allVariants.filter((variant) => {
        const locale = LanguageUtils.normalize(variant.language);
        return locale == closestLocale;
      });
    }

    // If we failed to get a language match, go with primary.
    if (selectedVariants.length == 0) {
      selectedVariants = allVariants.filter((variant) => {
        return variant.primary;
      });
    }

    // Otherwise, there is no good way to choose the language, so we don't
    // choose a language at all.
    if (selectedVariants.length == 0) {
      // Issue a warning, but only if the content has multiple languages.
      // Otherwise, this warning would just be noise.
      const languages = new Set(allVariants.map((track) => {
        return track.language;
      }));

      if (languages.size > 1) {
        shaka.log.warning('Could not choose a good audio track based on ' +
                          'language preferences or primary tracks.  An ' +
                          'arbitrary language will be stored!');
      }

      // Default back to all variants.
      selectedVariants = allVariants;
    }

    // From previously selected variants, choose the SD ones (height <= 480).
    const tracksByHeight = selectedVariants.filter((track) => {
      return track.height && track.height <= 480;
    });

    // If variants don't have video or no video with height <= 480 was
    // found, proceed with the previously selected tracks.
    if (tracksByHeight.length) {
      // Sort by resolution, then select all variants which match the height
      // of the highest SD res.  There may be multiple audio bitrates for the
      // same video resolution.
      tracksByHeight.sort((a, b) => {
        // The items in this list have already been screened for height, but the
        // compiler doesn't know that.
        goog.asserts.assert(a.height != null, 'Null height');
        goog.asserts.assert(b.height != null, 'Null height');

        return b.height - a.height;
      });
      selectedVariants = tracksByHeight.filter((track) => {
        return track.height == tracksByHeight[0].height;
      });
    }

    /** @type {!Array.<shaka.extern.Track>} */
    const selectedTracks = [];

    // If there are multiple matches at different audio bitrates, select the
    // middle bandwidth one.
    if (selectedVariants.length) {
      const middleIndex = Math.floor(selectedVariants.length / 2);
      selectedVariants.sort((a, b) => a.bandwidth - b.bandwidth);
      selectedTracks.push(selectedVariants[middleIndex]);
    }

    // Since this default callback is used primarily by our own demo app and by
    // app developers who haven't thought about which tracks they want, we
    // should select all image/text tracks, regardless of language.  This makes
    // for a better demo for us, and does not rely on user preferences for the
    // unconfigured app.
    for (const track of tracks) {
      if (track.type == ContentType.TEXT || track.type == ContentType.IMAGE) {
        selectedTracks.push(track);
      }
    }

    return selectedTracks;
  }

  /**
   * @param {!Element} element
   * @return {!Element}
   */
  static defaultManifestPreprocessor(element) {
    return shaka.util.ConfigUtils.referenceParametersAndReturn(
        [element],
        element);
  }

  /**
   * @param {!shaka.extern.xml.Node} element
   * @return {!shaka.extern.xml.Node}
   */
  static defaultManifestPreprocessorTXml(element) {
    return shaka.util.ConfigUtils.referenceParametersAndReturn(
        [element],
        element);
  }
};