Source: lib/util/player_configuration.js

/**
 * @license
 * Copyright 2016 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

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

goog.require('shaka.abr.SimpleAbrManager');


// TODO(vaage): Many times in our configs, we need to create an empty
//  implementation of a method, but to avoid closure from removing unused
//  parameters (and breaking our merge config code) we need to use each
//  parameter. Is there a better solution to this problem than what we are
//  doing now?
//
//  NOTE: Chrome App Content Security Policy prohibits usage of new Function()

/**
 * @final
 */
shaka.util.PlayerConfiguration = class {
  /** @return {shaka.extern.PlayerConfiguration} */
  static createDefault() {
    // This is a relatively safe default, since 3G cell connections
    // are faster than this.  For slower connections, such as 2G,
    // the default estimate may be too high.
    let bandwidthEstimate = 500e3; // 500kbps

    let abrMaxHeight = Infinity;

    // Some browsers implement the Network Information API, which allows
    // retrieving information about a user's network connection.
    //
    // We are excluding connection.type == undefined to avoid getting bogus data
    // on platforms where the implementation is incomplete.  Currently, desktop
    // Chrome 64 returns connection type undefined and a bogus downlink value.
    if (navigator.connection && navigator.connection.type) {
      // If it's available, get the bandwidth estimate from the browser (in
      // megabits per second) and use it as defaultBandwidthEstimate.
      bandwidthEstimate = navigator.connection.downlink * 1e6;
      // TODO: Move this into AbrManager, where changes to the estimate can be
      // observed and absorbed.

      // 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,
    };

    const manifest = {
      retryParameters: shaka.net.NetworkingEngine.defaultRetryParameters(),
      availabilityWindowOverride: NaN,
      dash: {
        // Reference node to keep closure from removing it.
        // If the argument is removed, it breaks our function length check
        // in mergeConfigObjects_().
        customScheme: (node) => {
          if (node) return null;
        },
        clockSyncUri: '',
        ignoreDrmInfo: false,
        xlinkFailGracefully: false,
        defaultPresentationDelay: 10,
      },
    };

    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.
      failureCallback: (error) => {
        shaka.log.error('Unhandled streaming error', error);
      },
      rebufferingGoal: 2,
      bufferingGoal: 10,
      bufferBehind: 30,
      ignoreTextStreamFailures: false,
      alwaysStreamText: false,
      startAtSegmentBoundary: false,
      smallGapLimit: 0.5,
      jumpLargeGaps: false,
      durationBackoff: 1,
      forceTransmuxTS: false,
    };

    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.
      trackSelectionCallback: (tracks) => tracks,

      // Need some operation in the callback or else closure may remove calls
      // to the function as it would be a no-op.
      progressCallback: (content, progress) => {
        shaka.log.v2('Offline operation on',
                     content.originalManifestUri,
                     'progress at',
                     progress);
      },

      // 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,
    };

    const abr = {
      enabled: true,
      defaultBandwidthEstimate: bandwidthEstimate,
      switchInterval: 8,
      bandwidthUpgradeTarget: 0.85,
      bandwidthDowngradeTarget: 0.95,
      restrictions: {
        minWidth: 0,
        maxWidth: Infinity,
        minHeight: 0,
        maxHeight: abrMaxHeight,
        minPixels: 0,
        maxPixels: Infinity,
        minBandwidth: 0,
        maxBandwidth: Infinity,
      },
    };

    /** @type {shaka.extern.PlayerConfiguration} */
    const config = {
      drm: drm,
      manifest: manifest,
      streaming: streaming,
      offline: offline,
      abrFactory: shaka.abr.SimpleAbrManager,
      abr: abr,
      preferredAudioLanguage: '',
      preferredTextLanguage: '',
      preferredVariantRole: '',
      preferredTextRole: '',
      preferredAudioChannelCount: 2,
      restrictions: {
        minWidth: 0,
        maxWidth: Infinity,
        minHeight: 0,
        maxHeight: Infinity,
        minPixels: 0,
        maxPixels: Infinity,
        minBandwidth: 0,
        maxBandwidth: Infinity,
      },
      playRangeStart: 0,
      playRangeEnd: Infinity,
      textDisplayFactory: () => null,
    };

    // 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.
    offline.trackSelectionCallback = (tracks) => {
      return shaka.util.PlayerConfiguration.defaultTrackSelect(
          tracks, config.preferredAudioLanguage);
    };

    return config;
  }

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

    let selectedTracks = [];

    // Select variants with best language match.
    let audioLangPref = LanguageUtils.normalize(preferredAudioLanguage);
    let matchTypes = [
      LanguageUtils.MatchType.EXACT,
      LanguageUtils.MatchType.BASE_LANGUAGE_OKAY,
      LanguageUtils.MatchType.OTHER_SUB_LANGUAGE_OKAY,
    ];
    let allVariantTracks =
        tracks.filter(function(track) { return track.type == 'variant'; });
    // For each match type, get the tracks that match the audio preference for
    // that match type.
    let tracksByMatchType = matchTypes.map(function(match) {
      return allVariantTracks.filter(function(track) {
        let lang = LanguageUtils.normalize(track.language);
        return LanguageUtils.match(match, audioLangPref, lang);
      });
    });

    // Find the best match type that has any matches.
    let variantTracks;
    for (let i = 0; i < tracksByMatchType.length; i++) {
      if (tracksByMatchType[i].length) {
        variantTracks = tracksByMatchType[i];
        break;
      }
    }

    // Fall back to "primary" audio tracks, if present.
    if (!variantTracks) {
      let primaryTracks = allVariantTracks.filter(function(track) {
        return track.primary;
      });
      if (primaryTracks.length) {
        variantTracks = primaryTracks;
      }
    }

    // Otherwise, there is no good way to choose the language, so we don't
    // choose a language at all.
    if (!variantTracks) {
      variantTracks = allVariantTracks;
      // Issue a warning, but only if the content has multiple languages.
      // Otherwise, this warning would just be noise.
      let languages = allVariantTracks
          .map(function(track) { return track.language; })
          .filter(shaka.util.Functional.isNotDuplicate);
      if (languages.length > 1) {
        shaka.log.warning('Could not choose a good audio track based on ' +
                          'language preferences or primary tracks.  An ' +
                          'arbitrary language will be stored!');
      }
    }

    // From previously selected variants, choose the SD ones (height <= 480).
    let tracksByHeight = variantTracks.filter(function(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(function(a, b) { return b.height - a.height; });
      variantTracks = tracksByHeight.filter(function(track) {
        return track.height == tracksByHeight[0].height;
      });
    }

    // Now sort by bandwidth.
    variantTracks.sort(function(a, b) { return a.bandwidth - b.bandwidth; });

    // If there are multiple matches at different audio bitrates, select the
    // middle bandwidth one.
    if (variantTracks.length) {
      selectedTracks.push(variantTracks[Math.floor(variantTracks.length / 2)]);
    }

    // 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 text tracks, regardless of language.  This makes for a
    // better demo for us, and does not rely on user preferences for the
    // unconfigured app.
    selectedTracks.push.apply(selectedTracks, tracks.filter(function(track) {
      return track.type == ContentType.TEXT;
    }));

    return selectedTracks;
  }
};