diff --git a/lib/polyfill/media_capabilities.js b/lib/polyfill/media_capabilities.js index 87cba19126..38bdceff70 100644 --- a/lib/polyfill/media_capabilities.js +++ b/lib/polyfill/media_capabilities.js @@ -8,6 +8,7 @@ goog.provide('shaka.polyfill.MediaCapabilities'); goog.require('shaka.log'); goog.require('shaka.polyfill'); +goog.require('shaka.util.Error'); goog.require('shaka.util.Platform'); @@ -96,37 +97,48 @@ shaka.polyfill.MediaCapabilities = class { return res; } + const videoConfig = mediaDecodingConfig['video']; + const audioConfig = mediaDecodingConfig['audio']; + if (mediaDecodingConfig.type == 'media-source') { if (!shaka.util.Platform.supportsMediaSource()) { return res; } // Use 'MediaSource.isTypeSupported' to check if the stream is supported. - if (mediaDecodingConfig['video']) { - const contentType = mediaDecodingConfig['video'].contentType; - const isSupported = MediaSource.isTypeSupported(contentType); + // Cast platforms will additionally check canDisplayType(), which + // accepts extended MIME type parameters. + // See: https://github.com/shaka-project/shaka-player/issues/4726 + if (videoConfig) { + let isSupported; + if (shaka.util.Platform.isChromecast()) { + isSupported = + shaka.polyfill.MediaCapabilities.canCastDisplayType_(videoConfig); + } else { + isSupported = MediaSource.isTypeSupported(videoConfig.contentType); + } if (!isSupported) { return res; } } - if (mediaDecodingConfig['audio']) { - const contentType = mediaDecodingConfig['audio'].contentType; + if (audioConfig) { + const contentType = audioConfig.contentType; const isSupported = MediaSource.isTypeSupported(contentType); if (!isSupported) { return res; } } } else if (mediaDecodingConfig.type == 'file') { - if (mediaDecodingConfig['video']) { - const contentType = mediaDecodingConfig['video'].contentType; + if (videoConfig) { + const contentType = videoConfig.contentType; const isSupported = shaka.util.Platform.supportsMediaType(contentType); if (!isSupported) { return res; } } - if (mediaDecodingConfig['audio']) { - const contentType = mediaDecodingConfig['audio'].contentType; + if (audioConfig) { + const contentType = audioConfig.contentType; const isSupported = shaka.util.Platform.supportsMediaType(contentType); if (!isSupported) { return res; @@ -217,6 +229,44 @@ shaka.polyfill.MediaCapabilities = class { return res; } + /** + * Checks if the given media parameters of the video or audio streams are + * supported by the Cast platform. + * @param {!VideoConfiguration} videoConfig The 'video' field of the + * MediaDecodingConfiguration. + * @return {boolean} `true` when the stream can be displayed on a Cast device. + * @private + */ + static canCastDisplayType_(videoConfig) { + if (!(window.cast)) { + shaka.log.error('Expected cast namespace to be available!'); + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.CAST, + shaka.util.Error.Code.CAST_API_UNAVAILABLE); + } else if (!(cast.__platform__ && cast.__platform__.canDisplayType)) { + shaka.log.warning('Expected cast APIs to be available! Falling back to ' + + 'MediaSource.isTypeSupported() for type support.'); + return MediaSource.isTypeSupported(videoConfig.contentType); + } + + let displayType = videoConfig.contentType; + if (videoConfig.width && videoConfig.height) { + displayType += + `; width=${videoConfig.width}; height=${videoConfig.height}`; + } + if (videoConfig.framerate) { + displayType += `; framerate=${videoConfig.framerate}`; + } + if (videoConfig.transferFunction === 'pq') { + // A "PQ" transfer function indicates this is an HDR-capable stream; + // "smpte2084" is the published standard. We need to inform the platform + // this query is specifically for HDR. + displayType += '; eotf=smpte2084'; + } + return cast.__platform__.canDisplayType(displayType); + } + /** * A method for generating a key for the MediaKeySystemAccessRequests cache. * diff --git a/test/polyfill/media_capabilities_unit.js b/test/polyfill/media_capabilities_unit.js index b709c93fd7..cda21813eb 100644 --- a/test/polyfill/media_capabilities_unit.js +++ b/test/polyfill/media_capabilities_unit.js @@ -5,13 +5,18 @@ */ describe('MediaCapabilities', () => { + const Util = shaka.test.Util; + const originalCast = window['cast']; const originalVendor = navigator.vendor; const originalUserAgent = navigator.userAgent; const originalRequestMediaKeySystemAccess = navigator.requestMediaKeySystemAccess; const originalMediaCapabilities = navigator.mediaCapabilities; + /** @type {MediaDecodingConfiguration} */ let mockDecodingConfig; + /** @type {!jasmine.Spy} */ + let mockCanDisplayType; beforeAll(() => { Object.defineProperty(window['navigator'], @@ -64,9 +69,17 @@ describe('MediaCapabilities', () => { }, }; shaka.polyfill.MediaCapabilities.memoizedMediaKeySystemAccessRequests_ = {}; + + mockCanDisplayType = jasmine.createSpy('canDisplayType'); + mockCanDisplayType.and.returnValue(false); + }); + + afterEach(() => { + window['cast'] = originalCast; }); afterAll(() => { + window['cast'] = originalCast; Object.defineProperty(window['navigator'], 'userAgent', {value: originalUserAgent}); Object.defineProperty(window['navigator'], @@ -172,5 +185,95 @@ describe('MediaCapabilities', () => { expect(requestKeySystemAccessSpy) .toHaveBeenCalledTimes(1); }); + + it('throws when the cast namespace is not available', async () => { + // Temporarily remove window.cast to trigger error. It's restored after + // every test. + delete window['cast']; + + const isChromecastSpy = + spyOn(shaka['util']['Platform'], + 'isChromecast').and.returnValue(true); + const expected = Util.jasmineError(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.CAST, + shaka.util.Error.Code.CAST_API_UNAVAILABLE)); + const isTypeSupportedSpy = + spyOn(window['MediaSource'], 'isTypeSupported').and.returnValue(true); + + shaka.polyfill.MediaCapabilities.install(); + await expectAsync( + navigator.mediaCapabilities.decodingInfo(mockDecodingConfig)) + .toBeRejectedWith(expected); + + expect(isTypeSupportedSpy).not.toHaveBeenCalled(); + // 1 (during install()) + 1 (for video config check). + expect(isChromecastSpy).toHaveBeenCalledTimes(2); + }); + + it('falls back to isTypeSupported() when canDisplayType() missing', + async () => { + // We only set the cast namespace, but not the canDisplayType() API. + window['cast'] = {}; + const isChromecastSpy = + spyOn(shaka['util']['Platform'], + 'isChromecast').and.returnValue(true); + const isTypeSupportedSpy = + spyOn(window['MediaSource'], 'isTypeSupported') + .and.returnValue(true); + + shaka.polyfill.MediaCapabilities.install(); + await navigator.mediaCapabilities.decodingInfo(mockDecodingConfig); + + expect(mockCanDisplayType).not.toHaveBeenCalled(); + // 1 (during install()) + 1 (for video config check). + expect(isChromecastSpy).toHaveBeenCalledTimes(2); + // 1 (fallback in canCastDisplayType()) + + // 1 (mockDecodingConfig.audio). + expect(isTypeSupportedSpy).toHaveBeenCalledTimes(2); + }); + + it('should use cast.__platform__.canDisplayType for "supported" field ' + + 'when platform is Cast', async () => { + // We're using quotes to access window.cast because the compiler + // knows about lots of Cast-specific APIs we aren't mocking. We + // don't need this mock strictly type-checked. + window['cast'] = { + __platform__: {canDisplayType: mockCanDisplayType}, + }; + const isChromecastSpy = + spyOn(shaka['util']['Platform'], + 'isChromecast').and.returnValue(true); + const isTypeSupportedSpy = + spyOn(window['MediaSource'], 'isTypeSupported').and.returnValue(true); + + // Tests an HDR stream's extended MIME type is correctly provided. + mockDecodingConfig.video.transferFunction = 'pq'; + mockDecodingConfig.video.contentType = + 'video/mp4; codecs="hev1.2.4.L153.B0"'; + // Round to a whole number since we can't rely on number => string + // conversion precision on all devices. + mockDecodingConfig.video.framerate = 24; + mockCanDisplayType.and.callFake((type) => { + expect(type).toBe( + 'video/mp4; ' + + 'codecs="hev1.2.4.L153.B0"; ' + + 'width=512; ' + + 'height=288; ' + + 'framerate=24; ' + + 'eotf=smpte2084'); + return true; + }); + + shaka.polyfill.MediaCapabilities.install(); + await navigator.mediaCapabilities.decodingInfo(mockDecodingConfig); + + // 1 (during install()) + 1 (for video config check). + expect(isChromecastSpy).toHaveBeenCalledTimes(2); + // 1 (mockDecodingConfig.audio). + expect(isTypeSupportedSpy).toHaveBeenCalledTimes(1); + // Called once in canCastDisplayType. + expect(mockCanDisplayType).toHaveBeenCalledTimes(1); + }); }); });