Files
shaka-player/lib/polyfill/mediasource.js
T
Joey Parrish 0e20f69e8d Fix Chromecast MSE polyfill
The Chromecast MediaSource polyfill was broken in "Avoid integer for
loops", Change-Id: I3ba3cb6a439264e823022b2a64e7cdbd265494c7.

This particular loop used to start at index 1, and the previous change
made it start at 0.  Because of a test infrastructure failure that
prevented testing on Chromecast, we missed this mistake.

This bug did not affect any release versions.

This fixes the bug by using shift() to remove the first item before
the loop, instead of [0] to access it in-place.

Change-Id: I3aa53436e23a1b389375df2b06b3bbbab18ecf0b
2019-07-17 20:26:05 +00:00

382 lines
14 KiB
JavaScript

/**
* @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.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.
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 (shaka.util.Platform.isApple()) {
const version = navigator.appVersion;
// 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_();
if (version.includes('Version/8')) {
// Safari 8 does not implement appendWindowEnd. If we ignore the
// incomplete MSE implementation, some content (especially multi-period)
// will fail to play correctly. The best we can do is blacklist Safari
// 8.
shaka.log.info('Blacklisting Safari 8 MSE.');
shaka.polyfill.MediaSource.blacklist_();
} else if (version.includes('Version/9')) {
shaka.log.info('Patching Safari 9 MSE bugs.');
// Safari 9 does not correctly implement abort() on SourceBuffer.
// Calling abort() causes a decoder failure, rather than resetting the
// decode timestamp as called for by the spec.
// Bug filed: https://bugs.webkit.org/show_bug.cgi?id=160316
shaka.polyfill.MediaSource.stubAbort_();
} else if (version.includes('Version/10')) {
shaka.log.info('Patching Safari 10 MSE bugs.');
// Safari 10 does not correctly implement abort() on SourceBuffer.
// Calling abort() before appending a segment causes that segment to be
// incomplete in buffer.
// Bug filed: https://bugs.webkit.org/show_bug.cgi?id=165342
shaka.polyfill.MediaSource.stubAbort_();
// Safari 10 fires spurious 'updateend' events after endOfStream().
// Bug filed: https://bugs.webkit.org/show_bug.cgi?id=165336
shaka.polyfill.MediaSource.patchEndOfStreamEvents_();
} else if (version.includes('Version/11') ||
version.includes('Version/12')) {
shaka.log.info('Patching Safari 11/12 MSE bugs.');
// Safari 11 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_();
// If you remove up to a keyframe, Safari 11 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 if (shaka.util.Platform.isTizen()) {
// 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.');
}
}
/**
* Blacklist the current browser by removing media source. A side-effect of
* this will be to make |shaka.util.Platform.supportsMediaSource| return
* |false|.
*
* @private
*/
static blacklist_() {
window['MediaSource'] = null;
}
/**
* 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 endOfStream() to get rid of 'updateend' events that should not fire.
* These extra events confuse MediaSourceEngine, which relies on correct
* events to manage SourceBuffer state.
*
* @private
*/
static patchEndOfStreamEvents_() {
// eslint-disable-next-line no-restricted-syntax
const endOfStream = MediaSource.prototype.endOfStream;
// eslint-disable-next-line no-restricted-syntax
MediaSource.prototype.endOfStream = function(...varArgs) {
// This bug manifests only when endOfStream() results in the truncation
// of the MediaSource's duration. So first we must calculate what the
// new duration will be.
let newDuration = 0;
for (const buffer of this.sourceBuffers || []) {
const bufferEnd = buffer.buffered.end(buffer.buffered.length - 1);
newDuration = Math.max(newDuration, bufferEnd);
}
// If the duration is going to be reduced, suppress the next 'updateend'
// event on each SourceBuffer.
if (!isNaN(this.duration) &&
newDuration < this.duration) {
this.ignoreUpdateEnd_ = true;
for (const buffer of this.sourceBuffers || []) {
buffer.eventSuppressed_ = false;
}
}
// eslint-disable-next-line no-restricted-syntax
return endOfStream.apply(this, varArgs);
};
let cleanUpHandlerInstalled = false;
// eslint-disable-next-line no-restricted-syntax
const addSourceBuffer = MediaSource.prototype.addSourceBuffer;
// eslint-disable-next-line no-restricted-syntax
MediaSource.prototype.addSourceBuffer = function(...varArgs) {
// After adding a new source buffer, add an event listener to allow us to
// suppress events.
// eslint-disable-next-line no-restricted-syntax
const sourceBuffer = addSourceBuffer.apply(this, varArgs);
sourceBuffer['mediaSource_'] = this;
sourceBuffer.addEventListener('updateend',
shaka.polyfill.MediaSource.ignoreUpdateEnd_, false);
if (!cleanUpHandlerInstalled) {
// If we haven't already, install an event listener to allow us to clean
// up listeners when MediaSource is torn down.
this.addEventListener('sourceclose',
shaka.polyfill.MediaSource.cleanUpListeners_, false);
cleanUpHandlerInstalled = true;
}
return sourceBuffer;
};
}
/**
* An event listener for 'updateend' which selectively suppresses the events.
*
* @see shaka.polyfill.MediaSource.patchEndOfStreamEvents_
*
* @param {Event} event
* @private
*/
static ignoreUpdateEnd_(event) {
const sourceBuffer = event.target;
const mediaSource = sourceBuffer['mediaSource_'];
if (mediaSource.ignoreUpdateEnd_) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
sourceBuffer.eventSuppressed_ = true;
for (const buffer of mediaSource.sourceBuffers) {
if (buffer.eventSuppressed_ == false) {
// More events need to be suppressed.
return;
}
}
// All events have been suppressed, all buffers are out of 'updating'
// mode. Stop suppressing events.
mediaSource.ignoreUpdateEnd_ = false;
}
}
/**
* An event listener for 'sourceclose' which cleans up listeners for
* 'updateend' to avoid memory leaks.
*
* @see shaka.polyfill.MediaSource.patchEndOfStreamEvents_
* @see shaka.polyfill.MediaSource.ignoreUpdateEnd_
*
* @param {Event} event
* @private
*/
static cleanUpListeners_(event) {
const mediaSource = /** @type {!MediaSource} */ (event.target);
for (const buffer of mediaSource.sourceBuffers || []) {
buffer.removeEventListener('updateend',
shaka.polyfill.MediaSource.ignoreUpdateEnd_, false);
}
mediaSource.removeEventListener('sourceclose',
shaka.polyfill.MediaSource.cleanUpListeners_, false);
}
/**
* 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);