Source: lib/polyfill/mediasource.js

  1. /**
  2. * @license
  3. * Copyright 2016 Google Inc.
  4. *
  5. * Licensed under the Apache License, Version 2.0 (the "License");
  6. * you may not use this file except in compliance with the License.
  7. * You may obtain a copy of the License at
  8. *
  9. * http://www.apache.org/licenses/LICENSE-2.0
  10. *
  11. * Unless required by applicable law or agreed to in writing, software
  12. * distributed under the License is distributed on an "AS IS" BASIS,
  13. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. * See the License for the specific language governing permissions and
  15. * limitations under the License.
  16. */
  17. goog.provide('shaka.polyfill.MediaSource');
  18. goog.require('shaka.log');
  19. goog.require('shaka.polyfill.register');
  20. goog.require('shaka.util.MimeUtils');
  21. goog.require('shaka.util.Platform');
  22. /**
  23. * @namespace shaka.polyfill.MediaSource
  24. *
  25. * @summary A polyfill to patch MSE bugs.
  26. */
  27. /**
  28. * Install the polyfill if needed.
  29. */
  30. shaka.polyfill.MediaSource.install = function() {
  31. shaka.log.debug('MediaSource.install');
  32. // MediaSource bugs are difficult to detect without checking for the affected
  33. // platform. SourceBuffer is not always exposed on window, for example, and
  34. // instances are only accessible after setting up MediaSource on a video
  35. // element. Because of this, we use UA detection and other platform detection
  36. // tricks to decide which patches to install.
  37. if (!window.MediaSource) {
  38. shaka.log.info('No MSE implementation available.');
  39. } else if (window.cast && cast.__platform__ &&
  40. cast.__platform__.canDisplayType) {
  41. shaka.log.info('Patching Chromecast MSE bugs.');
  42. // Chromecast cannot make accurate determinations via isTypeSupported.
  43. shaka.polyfill.MediaSource.patchCastIsTypeSupported_();
  44. } else if (navigator.vendor && navigator.vendor.indexOf('Apple') >= 0) {
  45. let version = navigator.appVersion;
  46. // TS content is broken on Safari in general.
  47. // See https://github.com/google/shaka-player/issues/743
  48. // and https://bugs.webkit.org/show_bug.cgi?id=165342
  49. shaka.polyfill.MediaSource.rejectTsContent_();
  50. if (version.indexOf('Version/8') >= 0) {
  51. // Safari 8 does not implement appendWindowEnd. If we ignore the
  52. // incomplete MSE implementation, some content (especially multi-period)
  53. // will fail to play correctly. The best we can do is blacklist Safari 8.
  54. shaka.log.info('Blacklisting Safari 8 MSE.');
  55. shaka.polyfill.MediaSource.blacklist_();
  56. } else if (version.indexOf('Version/9') >= 0) {
  57. shaka.log.info('Patching Safari 9 MSE bugs.');
  58. // Safari 9 does not correctly implement abort() on SourceBuffer.
  59. // Calling abort() causes a decoder failure, rather than resetting the
  60. // decode timestamp as called for by the spec.
  61. // Bug filed: http://goo.gl/UZ2rPp
  62. shaka.polyfill.MediaSource.stubAbort_();
  63. } else if (version.indexOf('Version/10') >= 0) {
  64. shaka.log.info('Patching Safari 10 MSE bugs.');
  65. // Safari 10 does not correctly implement abort() on SourceBuffer.
  66. // Calling abort() before appending a segment causes that segment to be
  67. // incomplete in buffer.
  68. // Bug filed: https://goo.gl/rC3CLj
  69. shaka.polyfill.MediaSource.stubAbort_();
  70. // Safari 10 fires spurious 'updateend' events after endOfStream().
  71. // Bug filed: https://goo.gl/qCeTZr
  72. shaka.polyfill.MediaSource.patchEndOfStreamEvents_();
  73. } else if (version.includes('Version/11') ||
  74. version.includes('Version/12')) {
  75. shaka.log.info('Patching Safari 11/12 MSE bugs.');
  76. // Safari 11 does not correctly implement abort() on SourceBuffer.
  77. // Calling abort() before appending a segment causes that segment to be
  78. // incomplete in the buffer.
  79. // Bug filed: https://goo.gl/rC3CLj
  80. shaka.polyfill.MediaSource.stubAbort_();
  81. // If you remove up to a keyframe, Safari 11 incorrectly will also remove
  82. // that keyframe and the content up to the next.
  83. // Offsetting the end of the removal range seems to help.
  84. // Bug filed: https://goo.gl/h2yDPN
  85. shaka.polyfill.MediaSource.patchRemovalRange_();
  86. }
  87. } else if (shaka.util.Platform.isTizen()) {
  88. // Tizen's implementation of MSE does not work well with opus. To prevent
  89. // the player from trying to play opus on Tizen, we will override media
  90. // source to always reject opus content.
  91. shaka.polyfill.MediaSource.rejectCodec_('opus');
  92. } else {
  93. shaka.log.info('Using native MSE as-is.');
  94. }
  95. };
  96. /**
  97. * Blacklist the current browser by making MediaSourceEngine.isBrowserSupported
  98. * fail later.
  99. *
  100. * @private
  101. */
  102. shaka.polyfill.MediaSource.blacklist_ = function() {
  103. window['MediaSource'] = null;
  104. };
  105. /**
  106. * Stub out abort(). On some buggy MSE implementations, calling abort() causes
  107. * various problems.
  108. *
  109. * @private
  110. */
  111. shaka.polyfill.MediaSource.stubAbort_ = function() {
  112. const addSourceBuffer = MediaSource.prototype.addSourceBuffer;
  113. MediaSource.prototype.addSourceBuffer = function() {
  114. let sourceBuffer = addSourceBuffer.apply(this, arguments);
  115. sourceBuffer.abort = function() {}; // Stub out for buggy implementations.
  116. return sourceBuffer;
  117. };
  118. };
  119. /**
  120. * Patch remove(). On Safari 11, if you call remove() to remove the content up
  121. * to a keyframe, Safari will also remove the keyframe and all of the data up to
  122. * the next one. For example, if the keyframes are at 0s, 5s, and 10s, and you
  123. * tried to remove 0s-5s, it would instead remove 0s-10s.
  124. *
  125. * Offsetting the end of the range seems to be a usable workaround.
  126. *
  127. * @private
  128. */
  129. shaka.polyfill.MediaSource.patchRemovalRange_ = function() {
  130. const originalRemove = SourceBuffer.prototype.remove;
  131. SourceBuffer.prototype.remove = function(startTime, endTime) {
  132. return originalRemove.call(this, startTime, endTime - 0.001);
  133. };
  134. };
  135. /**
  136. * Patch endOfStream() to get rid of 'updateend' events that should not fire.
  137. * These extra events confuse MediaSourceEngine, which relies on correct events
  138. * to manage SourceBuffer state.
  139. *
  140. * @private
  141. */
  142. shaka.polyfill.MediaSource.patchEndOfStreamEvents_ = function() {
  143. const endOfStream = MediaSource.prototype.endOfStream;
  144. MediaSource.prototype.endOfStream = function() {
  145. // This bug manifests only when endOfStream() results in the truncation
  146. // of the MediaSource's duration. So first we must calculate what the
  147. // new duration will be.
  148. let newDuration = 0;
  149. for (let i = 0; i < this.sourceBuffers.length; ++i) {
  150. let buffer = this.sourceBuffers[i];
  151. let bufferEnd = buffer.buffered.end(buffer.buffered.length - 1);
  152. newDuration = Math.max(newDuration, bufferEnd);
  153. }
  154. // If the duration is going to be reduced, suppress the next 'updateend'
  155. // event on each SourceBuffer.
  156. if (!isNaN(this.duration) &&
  157. newDuration < this.duration) {
  158. this.ignoreUpdateEnd_ = true;
  159. for (let i = 0; i < this.sourceBuffers.length; ++i) {
  160. let buffer = this.sourceBuffers[i];
  161. buffer.eventSuppressed_ = false;
  162. }
  163. }
  164. return endOfStream.apply(this, arguments);
  165. };
  166. let cleanUpHandlerInstalled = false;
  167. let addSourceBuffer = MediaSource.prototype.addSourceBuffer;
  168. MediaSource.prototype.addSourceBuffer = function() {
  169. // After adding a new source buffer, add an event listener to allow us to
  170. // suppress events.
  171. let sourceBuffer = addSourceBuffer.apply(this, arguments);
  172. sourceBuffer['mediaSource_'] = this;
  173. sourceBuffer.addEventListener('updateend',
  174. shaka.polyfill.MediaSource.ignoreUpdateEnd_, false);
  175. if (!cleanUpHandlerInstalled) {
  176. // If we haven't already, install an event listener to allow us to clean
  177. // up listeners when MediaSource is torn down.
  178. this.addEventListener('sourceclose',
  179. shaka.polyfill.MediaSource.cleanUpListeners_, false);
  180. cleanUpHandlerInstalled = true;
  181. }
  182. return sourceBuffer;
  183. };
  184. };
  185. /**
  186. * An event listener for 'updateend' which selectively suppresses the events.
  187. *
  188. * @see shaka.polyfill.MediaSource.patchEndOfStreamEvents_
  189. *
  190. * @param {Event} event
  191. * @private
  192. */
  193. shaka.polyfill.MediaSource.ignoreUpdateEnd_ = function(event) {
  194. let sourceBuffer = event.target;
  195. let mediaSource = sourceBuffer['mediaSource_'];
  196. if (mediaSource.ignoreUpdateEnd_) {
  197. event.preventDefault();
  198. event.stopPropagation();
  199. event.stopImmediatePropagation();
  200. sourceBuffer.eventSuppressed_ = true;
  201. for (let i = 0; i < mediaSource.sourceBuffers.length; ++i) {
  202. let buffer = mediaSource.sourceBuffers[i];
  203. if (buffer.eventSuppressed_ == false) {
  204. // More events need to be suppressed.
  205. return;
  206. }
  207. }
  208. // All events have been suppressed, all buffers are out of 'updating'
  209. // mode. Stop suppressing events.
  210. mediaSource.ignoreUpdateEnd_ = false;
  211. }
  212. };
  213. /**
  214. * An event listener for 'sourceclose' which cleans up listeners for 'updateend'
  215. * to avoid memory leaks.
  216. *
  217. * @see shaka.polyfill.MediaSource.patchEndOfStreamEvents_
  218. * @see shaka.polyfill.MediaSource.ignoreUpdateEnd_
  219. *
  220. * @param {Event} event
  221. * @private
  222. */
  223. shaka.polyfill.MediaSource.cleanUpListeners_ = function(event) {
  224. let mediaSource = /** @type {!MediaSource} */ (event.target);
  225. for (let i = 0; i < mediaSource.sourceBuffers.length; ++i) {
  226. let buffer = mediaSource.sourceBuffers[i];
  227. buffer.removeEventListener('updateend',
  228. shaka.polyfill.MediaSource.ignoreUpdateEnd_, false);
  229. }
  230. mediaSource.removeEventListener('sourceclose',
  231. shaka.polyfill.MediaSource.cleanUpListeners_, false);
  232. };
  233. /**
  234. * Patch isTypeSupported() to reject TS content. Used to avoid TS-related MSE
  235. * bugs on Safari.
  236. *
  237. * @private
  238. */
  239. shaka.polyfill.MediaSource.rejectTsContent_ = function() {
  240. const originalIsTypeSupported = MediaSource.isTypeSupported;
  241. MediaSource.isTypeSupported = function(mimeType) {
  242. // Parse the basic MIME type from its parameters.
  243. let pieces = mimeType.split(/ *; */);
  244. let basicMimeType = pieces[0];
  245. let container = basicMimeType.split('/')[1];
  246. if (container == 'mp2t') {
  247. return false;
  248. }
  249. return originalIsTypeSupported(mimeType);
  250. };
  251. };
  252. /**
  253. * Patch |MediaSource.isTypeSupported| to always reject |codec|. This is used
  254. * when we know that we are on a platform that does not work well with a given
  255. * codec.
  256. *
  257. * @param {string} codec
  258. * @private
  259. */
  260. shaka.polyfill.MediaSource.rejectCodec_ = function(codec) {
  261. const isTypeSupported = MediaSource.isTypeSupported;
  262. MediaSource.isTypeSupported = (mimeType) => {
  263. const actualCodec = shaka.util.MimeUtils.getCodecBase(mimeType);
  264. return actualCodec != codec && isTypeSupported(mimeType);
  265. };
  266. };
  267. /**
  268. * Patch isTypeSupported() to parse for HDR-related clues and chain to a private
  269. * API on the Chromecast which can query for support.
  270. *
  271. * @private
  272. */
  273. shaka.polyfill.MediaSource.patchCastIsTypeSupported_ = function() {
  274. const originalIsTypeSupported = MediaSource.isTypeSupported;
  275. // Docs from Dolby detailing profile names: https://goo.gl/LVVXrS
  276. let dolbyVisionRegex = /^dv(?:he|av)\./;
  277. MediaSource.isTypeSupported = function(mimeType) {
  278. // Parse the basic MIME type from its parameters.
  279. let pieces = mimeType.split(/ *; */);
  280. let basicMimeType = pieces[0];
  281. // Parse the parameters into key-value pairs.
  282. /** @type {Object.<string, string>} */
  283. let parameters = {};
  284. for (let i = 1; i < pieces.length; ++i) {
  285. let kv = pieces[i].split('=');
  286. let k = kv[0];
  287. let v = kv[1].replace(/"(.*)"/, '$1');
  288. parameters[k] = v;
  289. }
  290. let codecs = parameters['codecs'];
  291. if (!codecs) {
  292. return originalIsTypeSupported(mimeType);
  293. }
  294. let isHDR = false;
  295. let isDolbyVision = false;
  296. let codecList = codecs.split(',').filter(function(codec) {
  297. // Remove Dolby Vision codec strings. These are not understood on
  298. // Chromecast, even though the content can still be played.
  299. if (dolbyVisionRegex.test(codec)) {
  300. isDolbyVision = true;
  301. // Return false to remove this string from the list.
  302. return false;
  303. }
  304. // We take this string as a signal for HDR, but don't remove it.
  305. if (/^(hev|hvc)1\.2/.test(codec)) {
  306. isHDR = true;
  307. }
  308. // Keep all other strings in the list.
  309. return true;
  310. });
  311. // If the content uses Dolby Vision, we take this as a sign that the content
  312. // is not HDR after all.
  313. if (isDolbyVision) isHDR = false;
  314. // Reconstruct the "codecs" parameter from the results of the filter.
  315. parameters['codecs'] = codecList.join(',');
  316. // If the content is HDR, we add this additional parameter so that the Cast
  317. // platform will check for HDR support.
  318. if (isHDR) {
  319. parameters['eotf'] = 'smpte2084';
  320. }
  321. // Reconstruct the MIME type, possibly with additional parameters.
  322. let extendedMimeType = basicMimeType;
  323. for (let k in parameters) {
  324. let v = parameters[k];
  325. extendedMimeType += '; ' + k + '="' + v + '"';
  326. }
  327. return cast.__platform__.canDisplayType(extendedMimeType);
  328. };
  329. };
  330. shaka.polyfill.register(shaka.polyfill.MediaSource.install);