Source: lib/polyfill/mediasource.js

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

goog.provide('shaka.polyfill.MediaSource');

goog.require('shaka.log');
goog.require('shaka.polyfill');
goog.require('shaka.util.MimeUtils');
goog.require('shaka.util.Platform');

/**
 * @summary A polyfill to patch MSE bugs.
 */
shaka.polyfill.MediaSource = class {
  /**
   * Install the polyfill if needed.
   */
  static install() {
    shaka.log.debug('MediaSource.install');

    // MediaSource bugs are difficult to detect without checking for the
    // affected platform.  SourceBuffer is not always exposed on window, for
    // example, and instances are only accessible after setting up MediaSource
    // on a video element.  Because of this, we use UA detection and other
    // platform detection tricks to decide which patches to install.
    const safariVersion = shaka.util.Platform.safariVersion();

    if (!window.MediaSource) {
      shaka.log.info('No MSE implementation available.');
    } else if (window.cast && cast.__platform__ &&
               cast.__platform__.canDisplayType) {
      shaka.log.info('Patching Chromecast MSE bugs.');
      // Chromecast cannot make accurate determinations via isTypeSupported.
      shaka.polyfill.MediaSource.patchCastIsTypeSupported_();
    } else if (safariVersion) {
      // TS content is broken on Safari in general.
      // See https://github.com/google/shaka-player/issues/743
      // and https://bugs.webkit.org/show_bug.cgi?id=165342
      shaka.polyfill.MediaSource.rejectTsContent_();

      // NOTE:  shaka.Player.isBrowserSupported() has its own restrictions on
      // Safari version.
      if (safariVersion <= 12) {
        shaka.log.info('Patching Safari 11 & 12 MSE bugs.');
        // Safari 11 & 12 do not correctly implement abort() on SourceBuffer.
        // Calling abort() before appending a segment causes that segment to be
        // incomplete in the buffer.
        // Bug filed: https://bugs.webkit.org/show_bug.cgi?id=165342
        shaka.polyfill.MediaSource.stubAbort_();

        // If you remove up to a keyframe, Safari 11 & 12 incorrectly will also
        // remove that keyframe and the content up to the next.
        // Offsetting the end of the removal range seems to help.
        // Bug filed: https://bugs.webkit.org/show_bug.cgi?id=177884
        shaka.polyfill.MediaSource.patchRemovalRange_();
      } else {
        shaka.log.info('Patching Safari 13 MSE bugs.');
        // Safari 13 does not correctly implement abort() on SourceBuffer.
        // Calling abort() before appending a segment causes that segment to be
        // incomplete in the buffer.
        // Bug filed: https://bugs.webkit.org/show_bug.cgi?id=165342
        shaka.polyfill.MediaSource.stubAbort_();
      }
    } else if (shaka.util.Platform.isTizen2() ||
        shaka.util.Platform.isTizen3() ||
        shaka.util.Platform.isTizen4()) {
      // Tizen's implementation of MSE does not work well with opus. To prevent
      // the player from trying to play opus on Tizen, we will override media
      // source to always reject opus content.

      shaka.polyfill.MediaSource.rejectCodec_('opus');
    } else {
      shaka.log.info('Using native MSE as-is.');
    }
  }

  /**
   * Stub out abort().  On some buggy MSE implementations, calling abort()
   * causes various problems.
   *
   * @private
   */
  static stubAbort_() {
    /* eslint-disable no-restricted-syntax */
    const addSourceBuffer = MediaSource.prototype.addSourceBuffer;
    MediaSource.prototype.addSourceBuffer = function(...varArgs) {
      const sourceBuffer = addSourceBuffer.apply(this, varArgs);
      sourceBuffer.abort = function() {}; // Stub out for buggy implementations.
      return sourceBuffer;
    };
    /* eslint-enable no-restricted-syntax */
  }

  /**
   * Patch remove().  On Safari 11, if you call remove() to remove the content
   * up to a keyframe, Safari will also remove the keyframe and all of the data
   * up to the next one. For example, if the keyframes are at 0s, 5s, and 10s,
   * and you tried to remove 0s-5s, it would instead remove 0s-10s.
   *
   * Offsetting the end of the range seems to be a usable workaround.
   *
   * @private
   */
  static patchRemovalRange_() {
    // eslint-disable-next-line no-restricted-syntax
    const originalRemove = SourceBuffer.prototype.remove;

    // eslint-disable-next-line no-restricted-syntax
    SourceBuffer.prototype.remove = function(startTime, endTime) {
      // eslint-disable-next-line no-restricted-syntax
      return originalRemove.call(this, startTime, endTime - 0.001);
    };
  }

  /**
   * Patch isTypeSupported() to reject TS content.  Used to avoid TS-related MSE
   * bugs on Safari.
   *
   * @private
   */
  static rejectTsContent_() {
    const originalIsTypeSupported = MediaSource.isTypeSupported;

    MediaSource.isTypeSupported = (mimeType) => {
      // Parse the basic MIME type from its parameters.
      const pieces = mimeType.split(/ *; */);
      const basicMimeType = pieces[0];
      const container = basicMimeType.split('/')[1];

      if (container.toLowerCase() == 'mp2t') {
        return false;
      }

      return originalIsTypeSupported(mimeType);
    };
  }

  /**
   * Patch |MediaSource.isTypeSupported| to always reject |codec|. This is used
   * when we know that we are on a platform that does not work well with a given
   * codec.
   *
   * @param {string} codec
   * @private
   */
  static rejectCodec_(codec) {
    const isTypeSupported = MediaSource.isTypeSupported;

    MediaSource.isTypeSupported = (mimeType) => {
      const actualCodec = shaka.util.MimeUtils.getCodecBase(mimeType);
      return actualCodec != codec && isTypeSupported(mimeType);
    };
  }

  /**
   * Patch isTypeSupported() to parse for HDR-related clues and chain to a
   * private API on the Chromecast which can query for support.
   *
   * @private
   */
  static patchCastIsTypeSupported_() {
    const originalIsTypeSupported = MediaSource.isTypeSupported;

    // Docs from Dolby detailing profile names: https://bit.ly/2T2wKbu
    const dolbyVisionRegex = /^dv(?:h[e1]|a[v1])\./;

    MediaSource.isTypeSupported = (mimeType) => {
      // Parse the basic MIME type from its parameters.
      const pieces = mimeType.split(/ *; */);
      const basicMimeType = pieces.shift();  // Remove pieces[0].

      // Parse the parameters into key-value pairs.
      /** @type {Object.<string, string>} */
      const parameters = {};
      for (const piece of pieces) {
        const kv = piece.split('=');
        const k = kv[0];
        const v = kv[1].replace(/"(.*)"/, '$1');
        parameters[k] = v;
      }

      const codecs = parameters['codecs'];
      if (!codecs) {
        return originalIsTypeSupported(mimeType);
      }

      let isHDR = false;
      let isDolbyVision = false;

      const codecList = codecs.split(',').filter((codec) => {
        if (dolbyVisionRegex.test(codec)) {
          isDolbyVision = true;
        }

        // We take this string as a signal for HDR, but don't remove it.
        // This regex matches the strings "hev1.2" and "hvc1.2".
        if (/^(hev|hvc)1\.2/.test(codec)) {
          isHDR = true;
        }

        // Keep all other strings in the list.
        return true;
      });

      // If the content uses Dolby Vision, we take this as a sign that the
      // content is not HDR after all.
      if (isDolbyVision) {
        isHDR = false;
      }

      // Reconstruct the "codecs" parameter from the results of the filter.
      parameters['codecs'] = codecList.join(',');

      // If the content is HDR, we add this additional parameter so that the
      // Cast platform will check for HDR support.
      if (isHDR) {
        parameters['eotf'] = 'smpte2084';
      }

      // Reconstruct the MIME type, possibly with additional parameters.
      let extendedMimeType = basicMimeType;
      for (const k in parameters) {
        const v = parameters[k];
        extendedMimeType += '; ' + k + '="' + v + '"';
      }
      return cast.__platform__.canDisplayType(extendedMimeType);
    };
  }
};


shaka.polyfill.register(shaka.polyfill.MediaSource.install);