diff --git a/build/types/complete b/build/types/complete index 8ef1db0869..f2476defe2 100644 --- a/build/types/complete +++ b/build/types/complete @@ -2,6 +2,7 @@ +@ads +@cast ++@fairplay +@networking +@manifests +@polyfill diff --git a/build/types/core b/build/types/core index 27caffd2ea..17ce17f01f 100644 --- a/build/types/core +++ b/build/types/core @@ -68,7 +68,6 @@ +../../lib/util/ebml_parser.js +../../lib/util/error.js +../../lib/util/event_manager.js -+../../lib/util/fairplay_utils.js +../../lib/util/fake_event.js +../../lib/util/fake_event_target.js +../../lib/util/functional.js diff --git a/build/types/fairplay b/build/types/fairplay new file mode 100644 index 0000000000..6e77a1f2df --- /dev/null +++ b/build/types/fairplay @@ -0,0 +1,3 @@ +# FairPlay + ++../../lib/util/fairplay_utils.js \ No newline at end of file diff --git a/build/types/polyfill b/build/types/polyfill index 93e134a177..93ea505a19 100644 --- a/build/types/polyfill +++ b/build/types/polyfill @@ -7,6 +7,7 @@ +../../lib/polyfill/mediasource.js +../../lib/polyfill/media_capabilities.js +../../lib/polyfill/orientation.js ++../../lib/polyfill/patchedmediakeys_apple.js +../../lib/polyfill/patchedmediakeys_nop.js +../../lib/polyfill/patchedmediakeys_webkit.js +../../lib/polyfill/pip_webkit.js diff --git a/docs/tutorials/fairplay.md b/docs/tutorials/fairplay.md index 7c349178df..30057f1ae4 100644 --- a/docs/tutorials/fairplay.md +++ b/docs/tutorials/fairplay.md @@ -1,8 +1,41 @@ # FairPlay Support We support FairPlay with EME on compatible environments or native `src=`. + +By default Shaka Player supports Modern EME, if your provider doesn't support +Modern EME yet, you can use legacy Apple Media Keys with: +```js +shaka.polyfill.PatchedMediaKeysApple.install(); +``` + +The support in each case would be the following: + +| |Modern EME |legacy Apple Media Keys| +|:----------:|:---------:|:---------------------:| +|src= (CMAF) |**Y** |**Y** | +|src= (TS) |**Y** |**Y** | +|MSE (CMAF) |**Y** | - | +|MSE (TS) | - | - | + + Adding FairPlay support involves a bit more work than other key systems. + +## Keysystem used in EME + +Depending on the EME implementation that is being used, the Fairplay keysystem +varies. + +For Modern EME: +``` +com.apple.fps +``` + +For legacy Apple Media Keys: +``` +com.apple.fps.1_0 +``` + ## Server certificate All FairPlay content requires setting a server certificate. You can either @@ -22,6 +55,27 @@ player.configure('drm.advanced.com\\.apple\\.fps\\.serverCertificateUri', 'https://example.com/cert.der'); ``` +## Content ID + +Note: Normally only applies to legacy Apple Media Keys but some providers also +need it in Modern EME. + +Some FairPlay content use custom signaling for the content ID. The content ID +is used by the browser to generate the license request. If you don't use the +default content ID derivation, you need to specify a custom init data transform: + +```js +player.configure('drm.initDataTransform', (initData, initDataType) => { + if (initDataType != 'skd') + return initData; + // 'initData' is a buffer containing an 'skd://' URL as a UTF-8 string. + const skdUri = shaka.util.StringUtils.fromBytesAutoDetect(initData); + const contentId = getMyContentId(skdUri); + const cert = player.drmInfo().serverCertificate; + return shaka.util.FairPlayUtils.initDataTransform(initData, contentId, cert); +}); +``` + ## License wrapping Some FairPlay servers need to accept the license request in a different format @@ -61,3 +115,98 @@ player.getNetworkingEngine().registerResponseFilter((type, response) => { response.data = shaka.util.Uint8ArrayUtils.fromBase64(responseText).buffer; }); ``` + +### Integration with some DRMs providers + +Note: Some providers support both Modern EME and legacy Apple Media Keys. + +#### EZDRM (Modern EME) + +For integration with EZDRM the following can be used: + +```js +const FairPlayUtils = shaka.util.FairPlayUtils; +player.getNetworkingEngine() + .registerRequestFilter(FairPlayUtils.ezdrmFairPlayRequest); +player.getNetworkingEngine() + .registerResponseFilter(FairPlayUtils.commonFairPlayResponse); +``` + +Note: If the url of the license server has to undergo any transformation +(eg: add the contentId), you would have to create your filter manually. + +```js +player.getNetworkingEngine().registerRequestFilter((type, request) => { + if (type != shaka.net.NetworkingEngine.RequestType.LICENSE) { + return; + } + const uri = request.uris[0]; + const FairPlayUtils = shaka.util.FairPlayUtils; + const contentId = FairPlayUtils.defaultGetContentId(request.initData); + const newUri = uri.replace('^assetId^', contentId); + request.uris = [newUri]; + request.headers['Content-Type'] = 'application/octet-stream' +}); +``` + +#### EZDRM (legacy Apple Media Keys) + +For integration with EZDRM the following can be used: + +```js +shaka.polyfill.PatchedMediaKeysApple.install(); +const FairPlayUtils = shaka.util.FairPlayUtils; +player.getNetworkingEngine() + .registerRequestFilter(FairPlayUtils.ezdrmFairPlayRequest); +player.getNetworkingEngine() + .registerResponseFilter(FairPlayUtils.commonFairPlayResponse); +player.configure('drm.initDataTransform', + FairPlayUtils.ezdrmInitDataTransform); +``` + +Note: If the url of the license server has to undergo any transformation +(eg: add the contentId), you would have to create your filter manually. + +```js +player.getNetworkingEngine().registerRequestFilter((type, request) => { + if (type != shaka.net.NetworkingEngine.RequestType.LICENSE) { + return; + } + const uri = request.uris[0]; + const FairPlayUtils = shaka.util.FairPlayUtils; + const contentId = FairPlayUtils.defaultGetContentId(request.initData); + const newUri = uri.replace('^assetId^', contentId); + request.uris = [newUri]; + request.headers['Content-Type'] = 'application/octet-stream' +}); +``` + +#### Verimatrix (legacy Apple Media Keys) + +For integration with Verimatrix the following can be used: + +```js +shaka.polyfill.PatchedMediaKeysApple.install(); +const FairPlayUtils = shaka.util.FairPlayUtils; +player.getNetworkingEngine() + .registerRequestFilter(FairPlayUtils.verimatrixFairPlayRequest); +player.getNetworkingEngine() + .registerResponseFilter(FairPlayUtils.commonFairPlayResponse); +player.configure('drm.initDataTransform', + FairPlayUtils.verimatrixInitDataTransform); +``` + +#### Conax (legacy Apple Media Keys) + +For integration with Conax the following can be used: + +```js +shaka.polyfill.PatchedMediaKeysApple.install(); +const FairPlayUtils = shaka.util.FairPlayUtils; +player.getNetworkingEngine() + .registerRequestFilter(FairPlayUtils.conaxFairPlayRequest); +player.getNetworkingEngine() + .registerResponseFilter(FairPlayUtils.commonFairPlayResponse); +player.configure('drm.initDataTransform', + FairPlayUtils.conaxInitDataTransform); +``` diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 970012a3c1..bb501da250 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -2607,6 +2607,14 @@ shaka.hls.HlsParser = class { shaka.util.Error.Code.HLS_MSE_ENCRYPTED_MP2T_NOT_SUPPORTED); } + if (shaka.util.Platform.isMediaKeysPolyfilled()) { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code + .HLS_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED); + } + /* * Even if we're not able to construct initData through the HLS tag, adding * a DRMInfo will allow DRM Engine to request a media key system access diff --git a/lib/media/drm_engine.js b/lib/media/drm_engine.js index 71a3de3127..fad42a2990 100644 --- a/lib/media/drm_engine.js +++ b/lib/media/drm_engine.js @@ -1668,8 +1668,6 @@ shaka.media.DrmEngine = class { 'com.widevine.alpha', 'com.microsoft.playready', 'com.microsoft.playready.recommendation', - 'com.apple.fps.3_0', - 'com.apple.fps.2_0', 'com.apple.fps.1_0', 'com.apple.fps', 'com.adobe.primetime', diff --git a/lib/polyfill/patchedmediakeys_apple.js b/lib/polyfill/patchedmediakeys_apple.js new file mode 100644 index 0000000000..1a4e313f31 --- /dev/null +++ b/lib/polyfill/patchedmediakeys_apple.js @@ -0,0 +1,729 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.polyfill.PatchedMediaKeysApple'); + +goog.require('goog.asserts'); +goog.require('shaka.log'); +goog.require('shaka.media.DrmEngine'); +goog.require('shaka.polyfill'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.EventManager'); +goog.require('shaka.util.FakeEvent'); +goog.require('shaka.util.FakeEventTarget'); +goog.require('shaka.util.MediaReadyState'); +goog.require('shaka.util.PublicPromise'); +goog.require('shaka.util.StringUtils'); + + +/** + * @summary A polyfill to implement modern, standardized EME on top of Apple's + * prefixed EME in Safari. + * @export + */ +shaka.polyfill.PatchedMediaKeysApple = class { + /** + * Installs the polyfill if needed. + * @export + */ + static install() { + if (!window.HTMLVideoElement || !window.WebKitMediaKeys) { + // No HTML5 video or no prefixed EME. + return; + } + + shaka.log.info('Using Apple-prefixed EME'); + + // Alias + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + + // Delete mediaKeys to work around strict mode compatibility issues. + // eslint-disable-next-line no-restricted-syntax + delete HTMLMediaElement.prototype['mediaKeys']; + // Work around read-only declaration for mediaKeys by using a string. + // eslint-disable-next-line no-restricted-syntax + HTMLMediaElement.prototype['mediaKeys'] = null; + // eslint-disable-next-line no-restricted-syntax + HTMLMediaElement.prototype.setMediaKeys = + PatchedMediaKeysApple.setMediaKeys; + + // Install patches + window.MediaKeys = PatchedMediaKeysApple.MediaKeys; + window.MediaKeySystemAccess = PatchedMediaKeysApple.MediaKeySystemAccess; + navigator.requestMediaKeySystemAccess = + PatchedMediaKeysApple.requestMediaKeySystemAccess; + + window.shakaMediaKeysPolyfill = true; + } + + /** + * An implementation of navigator.requestMediaKeySystemAccess. + * Retrieves a MediaKeySystemAccess object. + * + * @this {!Navigator} + * @param {string} keySystem + * @param {!Array.} supportedConfigurations + * @return {!Promise.} + */ + static requestMediaKeySystemAccess(keySystem, supportedConfigurations) { + shaka.log.debug('PatchedMediaKeysApple.requestMediaKeySystemAccess'); + goog.asserts.assert(this == navigator, + 'bad "this" for requestMediaKeySystemAccess'); + + // Alias. + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + try { + const access = new PatchedMediaKeysApple.MediaKeySystemAccess( + keySystem, supportedConfigurations); + return Promise.resolve(/** @type {!MediaKeySystemAccess} */ (access)); + } catch (exception) { + return Promise.reject(exception); + } + } + + /** + * An implementation of HTMLMediaElement.prototype.setMediaKeys. + * Attaches a MediaKeys object to the media element. + * + * @this {!HTMLMediaElement} + * @param {MediaKeys} mediaKeys + * @return {!Promise} + */ + static setMediaKeys(mediaKeys) { + shaka.log.debug('PatchedMediaKeysApple.setMediaKeys'); + goog.asserts.assert(this instanceof HTMLMediaElement, + 'bad "this" for setMediaKeys'); + + // Alias + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + + const newMediaKeys = + /** @type {shaka.polyfill.PatchedMediaKeysApple.MediaKeys} */ ( + mediaKeys); + const oldMediaKeys = + /** @type {shaka.polyfill.PatchedMediaKeysApple.MediaKeys} */ ( + this.mediaKeys); + + if (oldMediaKeys && oldMediaKeys != newMediaKeys) { + goog.asserts.assert( + oldMediaKeys instanceof PatchedMediaKeysApple.MediaKeys, + 'non-polyfill instance of oldMediaKeys'); + // Have the old MediaKeys stop listening to events on the video tag. + oldMediaKeys.setMedia(null); + } + + delete this['mediaKeys']; // in case there is an existing getter + this['mediaKeys'] = mediaKeys; // work around read-only declaration + + if (newMediaKeys) { + goog.asserts.assert( + newMediaKeys instanceof PatchedMediaKeysApple.MediaKeys, + 'non-polyfill instance of newMediaKeys'); + return newMediaKeys.setMedia(this); + } + + return Promise.resolve(); + } + + /** + * Handler for the native media elements webkitneedkey event. + * + * @this {!HTMLMediaElement} + * @param {!MediaKeyEvent} event + * @suppress {constantProperty} We reassign what would be const on a real + * MediaEncryptedEvent, but in our look-alike event. + * @private + */ + static onWebkitNeedKey_(event) { + shaka.log.debug('PatchedMediaKeysApple.onWebkitNeedKey_', event); + + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + const mediaKeys = + /** @type {shaka.polyfill.PatchedMediaKeysApple.MediaKeys} */( + this.mediaKeys); + goog.asserts.assert(mediaKeys instanceof PatchedMediaKeysApple.MediaKeys, + 'non-polyfill instance of newMediaKeys'); + + goog.asserts.assert(event.initData != null, 'missing init data!'); + + // Convert the prefixed init data to match the native 'encrypted' event. + const uint8 = shaka.util.BufferUtils.toUint8(event.initData); + const dataview = shaka.util.BufferUtils.toDataView(uint8); + // The first part is a 4 byte little-endian int, which is the length of + // the second part. + const length = dataview.getUint32( + /* position= */ 0, /* littleEndian= */ true); + if (length + 4 != uint8.byteLength) { + throw new RangeError('Malformed FairPlay init data'); + } + // The remainder is a UTF-16 skd URL. Convert this to UTF-8 and pass on. + const str = shaka.util.StringUtils.fromUTF16( + uint8.subarray(4), /* littleEndian= */ true); + const initData = shaka.util.StringUtils.toUTF8(str); + + // NOTE: Because "this" is a real EventTarget, the event we dispatch here + // must also be a real Event. + const event2 = new Event('encrypted'); + + const encryptedEvent = + /** @type {!MediaEncryptedEvent} */(/** @type {?} */(event2)); + encryptedEvent.initDataType = 'skd'; + encryptedEvent.initData = shaka.util.BufferUtils.toArrayBuffer(initData); + + this.dispatchEvent(event2); + } +}; + + +/** + * An implementation of MediaKeySystemAccess. + * + * @implements {MediaKeySystemAccess} + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySystemAccess = class { + /** + * @param {string} keySystem + * @param {!Array.} supportedConfigurations + */ + constructor(keySystem, supportedConfigurations) { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySystemAccess'); + + /** @type {string} */ + this.keySystem = keySystem; + + /** @private {!MediaKeySystemConfiguration} */ + this.configuration_; + + // Optimization: WebKitMediaKeys.isTypeSupported delays responses by a + // significant amount of time, possibly to discourage fingerprinting. + // Since we know only FairPlay is supported here, let's skip queries for + // anything else to speed up the process. + if (keySystem.startsWith('com.apple.fps')) { + for (const cfg of supportedConfigurations) { + const newCfg = this.checkConfig_(cfg); + if (newCfg) { + this.configuration_ = newCfg; + return; + } + } + } + + // According to the spec, this should be a DOMException, but there is not a + // public constructor for that. So we make this look-alike instead. + const unsupportedKeySystemError = new Error('Unsupported keySystem'); + unsupportedKeySystemError.name = 'NotSupportedError'; + unsupportedKeySystemError['code'] = DOMException.NOT_SUPPORTED_ERR; + throw unsupportedKeySystemError; + } + + /** + * Check a single config for MediaKeySystemAccess. + * + * @param {MediaKeySystemConfiguration} cfg The requested config. + * @return {?MediaKeySystemConfiguration} A matching config we can support, or + * null if the input is not supportable. + * @private + */ + checkConfig_(cfg) { + if (cfg.persistentState == 'required') { + // Not supported by the prefixed API. + return null; + } + + // Create a new config object and start adding in the pieces which we find + // support for. We will return this from getConfiguration() later if + // asked. + + /** @type {!MediaKeySystemConfiguration} */ + const newCfg = { + 'audioCapabilities': [], + 'videoCapabilities': [], + // It is technically against spec to return these as optional, but we + // don't truly know their values from the prefixed API: + 'persistentState': 'optional', + 'distinctiveIdentifier': 'optional', + // Pretend the requested init data types are supported, since we don't + // really know that either: + 'initDataTypes': cfg.initDataTypes, + 'sessionTypes': ['temporary'], + 'label': cfg.label, + }; + + // PatchedMediaKeysApple tests for key system availability through + // WebKitMediaKeys.isTypeSupported. + let ranAnyTests = false; + let success = false; + + if (cfg.audioCapabilities) { + for (const cap of cfg.audioCapabilities) { + if (cap.contentType) { + ranAnyTests = true; + + const contentType = cap.contentType.split(';')[0]; + if (WebKitMediaKeys.isTypeSupported(this.keySystem, contentType)) { + newCfg.audioCapabilities.push(cap); + success = true; + } + } + } + } + + if (cfg.videoCapabilities) { + for (const cap of cfg.videoCapabilities) { + if (cap.contentType) { + ranAnyTests = true; + + const contentType = cap.contentType.split(';')[0]; + if (WebKitMediaKeys.isTypeSupported(this.keySystem, contentType)) { + newCfg.videoCapabilities.push(cap); + success = true; + } + } + } + } + + if (!ranAnyTests) { + // If no specific types were requested, we check all common types to + // find out if the key system is present at all. + success = WebKitMediaKeys.isTypeSupported(this.keySystem, 'video/mp4'); + } + + if (success) { + return newCfg; + } + return null; + } + + /** @override */ + createMediaKeys() { + shaka.log.debug( + 'PatchedMediaKeysApple.MediaKeySystemAccess.createMediaKeys'); + + // Alias + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + + const mediaKeys = new PatchedMediaKeysApple.MediaKeys(this.keySystem); + return Promise.resolve(/** @type {!MediaKeys} */ (mediaKeys)); + } + + /** @override */ + getConfiguration() { + shaka.log.debug( + 'PatchedMediaKeysApple.MediaKeySystemAccess.getConfiguration'); + return this.configuration_; + } +}; + + +/** + * An implementation of MediaKeys. + * + * @implements {MediaKeys} + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeys = class { + /** @param {string} keySystem */ + constructor(keySystem) { + shaka.log.debug('PatchedMediaKeysApple.MediaKeys'); + + /** @private {!WebKitMediaKeys} */ + this.nativeMediaKeys_ = new WebKitMediaKeys(keySystem); + + /** @private {!shaka.util.EventManager} */ + this.eventManager_ = new shaka.util.EventManager(); + } + + /** @override */ + createSession(sessionType) { + shaka.log.debug('PatchedMediaKeysApple.MediaKeys.createSession'); + + sessionType = sessionType || 'temporary'; + // For now, only the 'temporary' type is supported. + if (sessionType != 'temporary') { + throw new TypeError('Session type ' + sessionType + + ' is unsupported on this platform.'); + } + + // Alias + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + + return new PatchedMediaKeysApple.MediaKeySession( + this.nativeMediaKeys_, sessionType); + } + + /** @override */ + setServerCertificate(serverCertificate) { + shaka.log.debug('PatchedMediaKeysApple.MediaKeys.setServerCertificate'); + return Promise.resolve(false); + } + + /** + * @param {HTMLMediaElement} media + * @protected + * @return {!Promise} + */ + setMedia(media) { + // Alias + const PatchedMediaKeysApple = shaka.polyfill.PatchedMediaKeysApple; + + // Remove any old listeners. + this.eventManager_.removeAll(); + + // It is valid for media to be null; null is used to flag that event + // handlers need to be cleaned up. + if (!media) { + return Promise.resolve(); + } + + // Intercept and translate these prefixed EME events. + this.eventManager_.listen(media, 'webkitneedkey', + /** @type {shaka.util.EventManager.ListenerType} */ + (PatchedMediaKeysApple.onWebkitNeedKey_)); + + // Wrap native HTMLMediaElement.webkitSetMediaKeys with a Promise. + try { + // Some browsers require that readyState >=1 before mediaKeys can be + // set, so check this and wait for loadedmetadata if we are not in the + // correct state + shaka.util.MediaReadyState.waitForReadyState(media, + HTMLMediaElement.HAVE_METADATA, + this.eventManager_, () => { + media.webkitSetMediaKeys(this.nativeMediaKeys_); + }); + + return Promise.resolve(); + } catch (exception) { + return Promise.reject(exception); + } + } +}; + + +/** + * An implementation of MediaKeySession. + * + * @implements {MediaKeySession} + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeySession = +class extends shaka.util.FakeEventTarget { + /** + * @param {WebKitMediaKeys} nativeMediaKeys + * @param {string} sessionType + */ + constructor(nativeMediaKeys, sessionType) { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySession'); + super(); + + /** + * The native MediaKeySession, which will be created in generateRequest. + * @private {WebKitMediaKeySession} + */ + this.nativeMediaKeySession_ = null; + + /** @private {WebKitMediaKeys} */ + this.nativeMediaKeys_ = nativeMediaKeys; + + // Promises that are resolved later + /** @private {shaka.util.PublicPromise} */ + this.generateRequestPromise_ = null; + + /** @private {shaka.util.PublicPromise} */ + this.updatePromise_ = null; + + /** @private {!shaka.util.EventManager} */ + this.eventManager_ = new shaka.util.EventManager(); + + /** @type {string} */ + this.sessionId = ''; + + /** @type {number} */ + this.expiration = NaN; + + /** @type {!shaka.util.PublicPromise} */ + this.closed = new shaka.util.PublicPromise(); + + /** @type {!shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap} */ + this.keyStatuses = + new shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap(); + } + + /** @override */ + generateRequest(initDataType, initData) { + shaka.log.debug( + 'PatchedMediaKeysApple.MediaKeySession.generateRequest'); + + this.generateRequestPromise_ = new shaka.util.PublicPromise(); + + try { + // This EME spec version requires a MIME content type as the 1st param to + // createSession, but doesn't seem to matter what the value is. + // It also only accepts Uint8Array, not ArrayBuffer, so explicitly make + // initData into a Uint8Array. + const session = this.nativeMediaKeys_.createSession( + 'video/mp4', shaka.util.BufferUtils.toUint8(initData)); + this.nativeMediaKeySession_ = session; + this.sessionId = session.sessionId || ''; + + // Attach session event handlers here. + this.eventManager_.listen( + this.nativeMediaKeySession_, 'webkitkeymessage', + /** @type {shaka.util.EventManager.ListenerType} */ + ((event) => this.onWebkitKeyMessage_(event))); + this.eventManager_.listen(session, 'webkitkeyadded', + /** @type {shaka.util.EventManager.ListenerType} */ + ((event) => this.onWebkitKeyAdded_(event))); + this.eventManager_.listen(session, 'webkitkeyerror', + /** @type {shaka.util.EventManager.ListenerType} */ + ((event) => this.onWebkitKeyError_(event))); + + this.updateKeyStatus_('status-pending'); + } catch (exception) { + this.generateRequestPromise_.reject(exception); + } + + return this.generateRequestPromise_; + } + + /** @override */ + load() { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySession.load'); + + return Promise.reject(new Error('MediaKeySession.load not yet supported')); + } + + /** @override */ + update(response) { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySession.update'); + + this.updatePromise_ = new shaka.util.PublicPromise(); + + try { + // Pass through to the native session. + this.nativeMediaKeySession_.update( + shaka.util.BufferUtils.toUint8(response)); + } catch (exception) { + this.updatePromise_.reject(exception); + } + + return this.updatePromise_; + } + + /** @override */ + close() { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySession.close'); + + try { + // Pass through to the native session. + this.nativeMediaKeySession_.close(); + + this.closed.resolve(); + this.eventManager_.removeAll(); + } catch (exception) { + this.closed.reject(exception); + } + + return this.closed; + } + + /** @override */ + remove() { + shaka.log.debug('PatchedMediaKeysApple.MediaKeySession.remove'); + + return Promise.reject(new Error( + 'MediaKeySession.remove is only applicable for persistent licenses, ' + + 'which are not supported on this platform')); + } + + /** + * Handler for the native keymessage event on WebKitMediaKeySession. + * + * @param {!MediaKeyEvent} event + * @private + */ + onWebkitKeyMessage_(event) { + shaka.log.debug('PatchedMediaKeysApple.onWebkitKeyMessage_', event); + + // We can now resolve this.generateRequestPromise, which should be non-null. + goog.asserts.assert(this.generateRequestPromise_, + 'generateRequestPromise_ should be set before now!'); + if (this.generateRequestPromise_) { + this.generateRequestPromise_.resolve(); + this.generateRequestPromise_ = null; + } + + const isNew = this.keyStatuses.getStatus() == undefined; + + const data = new Map() + .set('messageType', isNew ? 'license-request' : 'license-renewal') + .set('message', shaka.util.BufferUtils.toArrayBuffer(event.message)); + const event2 = new shaka.util.FakeEvent('message', data); + + this.dispatchEvent(event2); + } + + /** + * Handler for the native keyadded event on WebKitMediaKeySession. + * + * @param {!MediaKeyEvent} event + * @private + */ + onWebkitKeyAdded_(event) { + shaka.log.debug('PatchedMediaKeysApple.onWebkitKeyAdded_', event); + + // This shouldn't fire while we're in the middle of generateRequest, + // but if it does, we will need to change the logic to account for it. + goog.asserts.assert(!this.generateRequestPromise_, + 'Key added during generate!'); + + // We can now resolve this.updatePromise, which should be non-null. + goog.asserts.assert(this.updatePromise_, + 'updatePromise_ should be set before now!'); + if (this.updatePromise_) { + this.updateKeyStatus_('usable'); + this.updatePromise_.resolve(); + this.updatePromise_ = null; + } + } + + /** + * Handler for the native keyerror event on WebKitMediaKeySession. + * + * @param {!MediaKeyEvent} event + * @private + */ + onWebkitKeyError_(event) { + shaka.log.debug('PatchedMediaKeysApple.onWebkitKeyError_', event); + + const error = new Error('EME PatchedMediaKeysApple key error'); + error['errorCode'] = this.nativeMediaKeySession_.error; + + if (this.generateRequestPromise_ != null) { + this.generateRequestPromise_.reject(error); + this.generateRequestPromise_ = null; + } else if (this.updatePromise_ != null) { + this.updatePromise_.reject(error); + this.updatePromise_ = null; + } else { + // Unexpected error - map native codes to standardised key statuses. + // Possible values of this.nativeMediaKeySession_.error.code: + // MEDIA_KEYERR_UNKNOWN = 1 + // MEDIA_KEYERR_CLIENT = 2 + // MEDIA_KEYERR_SERVICE = 3 + // MEDIA_KEYERR_OUTPUT = 4 + // MEDIA_KEYERR_HARDWARECHANGE = 5 + // MEDIA_KEYERR_DOMAIN = 6 + + switch (this.nativeMediaKeySession_.error.code) { + case WebKitMediaKeyError.MEDIA_KEYERR_OUTPUT: + case WebKitMediaKeyError.MEDIA_KEYERR_HARDWARECHANGE: + this.updateKeyStatus_('output-not-allowed'); + break; + default: + this.updateKeyStatus_('internal-error'); + break; + } + } + } + + /** + * Updates key status and dispatch a 'keystatuseschange' event. + * + * @param {string} status + * @private + */ + updateKeyStatus_(status) { + this.keyStatuses.setStatus(status); + const event = new shaka.util.FakeEvent('keystatuseschange'); + this.dispatchEvent(event); + } +}; + + +/** + * @summary An implementation of MediaKeyStatusMap. + * This fakes a map with a single key ID. + * + * @todo Consolidate the MediaKeyStatusMap types in these polyfills. + * @implements {MediaKeyStatusMap} + */ +shaka.polyfill.PatchedMediaKeysApple.MediaKeyStatusMap = class { + /** */ + constructor() { + /** + * @type {number} + */ + this.size = 0; + + /** + * @private {string|undefined} + */ + this.status_ = undefined; + } + + /** + * An internal method used by the session to set key status. + * @param {string|undefined} status + */ + setStatus(status) { + this.size = status == undefined ? 0 : 1; + this.status_ = status; + } + + /** + * An internal method used by the session to get key status. + * @return {string|undefined} + */ + getStatus() { + return this.status_; + } + + /** @override */ + forEach(fn) { + if (this.status_) { + fn(this.status_, shaka.media.DrmEngine.DUMMY_KEY_ID.value()); + } + } + + /** @override */ + get(keyId) { + if (this.has(keyId)) { + return this.status_; + } + return undefined; + } + + /** @override */ + has(keyId) { + const fakeKeyId = shaka.media.DrmEngine.DUMMY_KEY_ID.value(); + if (this.status_ && shaka.util.BufferUtils.equal(keyId, fakeKeyId)) { + return true; + } + return false; + } + + /** + * @suppress {missingReturn} + * @override + */ + entries() { + goog.asserts.assert(false, 'Not used! Provided only for the compiler.'); + } + + /** + * @suppress {missingReturn} + * @override + */ + keys() { + goog.asserts.assert(false, 'Not used! Provided only for the compiler.'); + } + + /** + * @suppress {missingReturn} + * @override + */ + values() { + goog.asserts.assert(false, 'Not used! Provided only for the compiler.'); + } +}; diff --git a/lib/util/error.js b/lib/util/error.js index 548766c824..529d740cf8 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -693,7 +693,11 @@ shaka.util.Error.Code = { */ 'HLS_MSE_ENCRYPTED_MP2T_NOT_SUPPORTED': 4040, - // RETIRED: 'HLS_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED': 4041, + /** + * We do not support playing encrypted content (different than mp2t) with MSE + * and legacy Apple MediaKeys API. + */ + 'HLS_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED': 4041, // RETIRED: 'INCONSISTENT_BUFFER_STATE': 5000, diff --git a/lib/util/fairplay_utils.js b/lib/util/fairplay_utils.js index 323a181697..9e114b0627 100644 --- a/lib/util/fairplay_utils.js +++ b/lib/util/fairplay_utils.js @@ -133,13 +133,83 @@ shaka.util.FairPlayUtils = class { } /** - * SPC FairPlay request. + * Verimatrix initDataTransform configuration. + * + * @param {!Uint8Array} initData + * @param {string} initDataType + * @param {?shaka.extern.DrmInfo} drmInfo + * @export + */ + static verimatrixInitDataTransform(initData, initDataType, drmInfo) { + if (initDataType !== 'skd') { + return initData; + } + const StringUtils = shaka.util.StringUtils; + const FairPlayUtils = shaka.util.FairPlayUtils; + const cert = drmInfo.serverCertificate; + const initDataAsString = StringUtils.fromBytesAutoDetect(initData); + const contentId = initDataAsString.split('skd://').pop(); + return FairPlayUtils.initDataTransform(initData, contentId, cert); + } + + /** + * EZDRM initDataTransform configuration. + * + * @param {!Uint8Array} initData + * @param {string} initDataType + * @param {?shaka.extern.DrmInfo} drmInfo + * @export + */ + static ezdrmInitDataTransform(initData, initDataType, drmInfo) { + if (initDataType !== 'skd') { + return initData; + } + const StringUtils = shaka.util.StringUtils; + const FairPlayUtils = shaka.util.FairPlayUtils; + const cert = drmInfo.serverCertificate; + const initDataAsString = StringUtils.fromBytesAutoDetect(initData); + const contentId = initDataAsString.split(';').pop(); + return FairPlayUtils.initDataTransform(initData, contentId, cert); + } + + /** + * Conax initDataTransform configuration. + * + * @param {!Uint8Array} initData + * @param {string} initDataType + * @param {?shaka.extern.DrmInfo} drmInfo + * @export + */ + static conaxInitDataTransform(initData, initDataType, drmInfo) { + if (initDataType !== 'skd') { + return initData; + } + const StringUtils = shaka.util.StringUtils; + const FairPlayUtils = shaka.util.FairPlayUtils; + const cert = drmInfo.serverCertificate; + const initDataAsString = StringUtils.fromBytesAutoDetect(initData); + const skdValue = initDataAsString.split('skd://').pop().split('?').shift(); + const stringToArray = (string) => { + // 2 bytes for each char + const buffer = new ArrayBuffer(string.length * 2); + const array = new Uint16Array(buffer); + for (let i = 0, strLen = string.length; i < strLen; i++) { + array[i] = string.charCodeAt(i); + } + return array; + }; + const contentId = stringToArray(window.atob(skdValue)); + return FairPlayUtils.initDataTransform(initData, contentId, cert); + } + + /** + * Verimatrix FairPlay request. * * @param {shaka.net.NetworkingEngine.RequestType} type * @param {shaka.extern.Request} request * @export */ - static spcFairPlayRequest(type, request) { + static verimatrixFairPlayRequest(type, request) { if (type !== shaka.net.NetworkingEngine.RequestType.LICENSE) { return; } @@ -150,6 +220,34 @@ shaka.util.FairPlayUtils = class { request.body = shaka.util.StringUtils.toUTF8('spc=' + base64Payload); } + /** + * EZDRM FairPlay request. + * + * @param {shaka.net.NetworkingEngine.RequestType} type + * @param {shaka.extern.Request} request + * @export + */ + static ezdrmFairPlayRequest(type, request) { + if (type !== shaka.net.NetworkingEngine.RequestType.LICENSE) { + return; + } + request.headers['Content-Type'] = 'application/octet-stream'; + } + + /** + * Conax FairPlay request. + * + * @param {shaka.net.NetworkingEngine.RequestType} type + * @param {shaka.extern.Request} request + * @export + */ + static conaxFairPlayRequest(type, request) { + if (type !== shaka.net.NetworkingEngine.RequestType.LICENSE) { + return; + } + request.headers['Content-Type'] = 'application/octet-stream'; + } + /** * Common FairPlay response transform for some DRMs providers. * diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index c1e3ff1bb4..91f009ceae 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -38,6 +38,7 @@ goog.require('shaka.polyfill.Fullscreen'); goog.require('shaka.polyfill.MediaSource'); goog.require('shaka.polyfill.MediaCapabilities'); goog.require('shaka.polyfill.Orientation'); +goog.require('shaka.polyfill.PatchedMediaKeysApple'); goog.require('shaka.polyfill.PatchedMediaKeysNop'); goog.require('shaka.polyfill.PatchedMediaKeysWebkit'); goog.require('shaka.polyfill.PiPWebkit');