diff --git a/README.md b/README.md index 1d19505..535bfaf 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,7 @@ player.src({ }, keySystems: { 'org.w3.clearkey': { + initDataTypes: ['cenc', 'webm'], audioContentType: 'audio/webm; codecs="vorbis"', videoContentType: 'video/webm; codecs="vp9"', getCertificate: function(emeOptions, callback) { diff --git a/src/eme.js b/src/eme.js index 6f13c50..b86d49e 100644 --- a/src/eme.js +++ b/src/eme.js @@ -2,6 +2,8 @@ import videojs from 'video.js'; import { requestPlayreadyLicense } from './playready'; import window from 'global/window'; import {mergeAndRemoveNull} from './utils'; +import {defaultGetCertificate as defaultFairplayGetCertificate, + defaultGetLicense as defaultFairplayGetLicense } from './fairplay'; export const getSupportedKeySystem = (keySystems) => { // As this happens after the src is set on the video, we rely only on the set src (we @@ -10,10 +12,14 @@ export const getSupportedKeySystem = (keySystems) => { let promise; Object.keys(keySystems).forEach((keySystem) => { - // TODO use initDataTypes when appropriate const systemOptions = {}; + const initDataTypes = keySystems[keySystem].initDataTypes || + // fairplay requires an explicit initDataTypes + (keySystem.startsWith('com.apple.fps') ? ['sinf'] : null); const audioContentType = keySystems[keySystem].audioContentType; - const videoContentType = keySystems[keySystem].videoContentType; + const videoContentType = keySystems[keySystem].videoContentType || + // fairplay requires an explicit videoCapabilities + (keySystem.startsWith('com.apple.fps') ? 'video/mp4' : null); if (audioContentType) { systemOptions.audioCapabilities = [{ @@ -25,6 +31,9 @@ export const getSupportedKeySystem = (keySystems) => { contentType: videoContentType }]; } + if (initDataTypes) { + systemOptions.initDataTypes = initDataTypes; + } if (!promise) { promise = window.navigator.requestMediaKeySystemAccess(keySystem, [systemOptions]); @@ -49,7 +58,6 @@ export const makeNewRequest = ({ const keySession = mediaKeys.createSession(); return new Promise((resolve, reject) => { - keySession.addEventListener('message', (event) => { getLicense(options, event.message) .then((license) => { @@ -199,10 +207,10 @@ const defaultGetLicense = (keySystemOptions) => (emeOptions, keyMessage, callbac }); }; -const promisifyGetLicense = (getLicenseFn, eventBus) => { +const promisifyGetLicense = (keySystem, getLicenseFn, eventBus) => { return (emeOptions, keyMessage) => { return new Promise((resolve, reject) => { - getLicenseFn(emeOptions, keyMessage, (err, license) => { + const callback = (err, license) => { if (eventBus) { eventBus.trigger('licenserequestattempted'); } @@ -212,7 +220,13 @@ const promisifyGetLicense = (getLicenseFn, eventBus) => { } resolve(license); - }); + }; + + if (keySystem.startsWith('com.apple.fps')) { + getLicenseFn(emeOptions, null, keyMessage, callback); + } else { + getLicenseFn(emeOptions, keyMessage, callback); + } }); }; }; @@ -221,15 +235,30 @@ const standardizeKeySystemOptions = (keySystem, keySystemOptions) => { if (typeof keySystemOptions === 'string') { keySystemOptions = { url: keySystemOptions }; } + if (typeof keySystemOptions.licenseUri !== 'undefined') { + keySystemOptions.url = keySystemOptions.licenseUri; + } if (!keySystemOptions.url && !keySystemOptions.getLicense) { - throw new Error('Neither URL nor getLicense function provided to get license'); + throw new Error('Missing configuration: one of url, licenseUri, or getLicense is required'); + } + + if (typeof keySystemOptions.certificateUri !== 'undefined') { + keySystemOptions.getCertificate = defaultFairplayGetCertificate(keySystemOptions); } if (keySystemOptions.url && !keySystemOptions.getLicense) { - keySystemOptions.getLicense = keySystem === 'com.microsoft.playready' ? - defaultPlayreadyGetLicense(keySystemOptions) : - defaultGetLicense(keySystemOptions); + if (keySystem === 'com.microsoft.playready') { + keySystemOptions.getLicense = defaultPlayreadyGetLicense(keySystemOptions); + } else if (keySystem.startsWith('com.apple.fps')) { + keySystemOptions.getLicense = defaultFairplayGetLicense(keySystemOptions); + } else { + keySystemOptions.getLicense = defaultGetLicense(keySystemOptions); + } + } + + if (keySystem.startsWith('com.apple.fps') && !keySystemOptions.getCertificate) { + throw new Error('Missing configuration: one of certificateUri or getCertificate is required'); } return keySystemOptions; @@ -245,6 +274,7 @@ export const standard5July2016 = ({ eventBus }) => { let keySystemPromise = Promise.resolve(); + const keySystem = keySystemAccess.keySystem; if (typeof video.mediaKeysObject === 'undefined') { // Prevent entering this path again. @@ -258,14 +288,14 @@ export const standard5July2016 = ({ keySystemPromise = new Promise((resolve, reject) => { // save key system for adding sessions - video.keySystem = keySystemAccess.keySystem; + video.keySystem = keySystem; keySystemOptions = standardizeKeySystemOptions( - keySystemAccess.keySystem, - options.keySystems[keySystemAccess.keySystem]); + keySystem, + options.keySystems[keySystem]); if (!keySystemOptions.getCertificate) { - resolve(keySystemAccess); + resolve(); return; } @@ -287,7 +317,7 @@ export const standard5July2016 = ({ certificate, createdMediaKeys, options, - getLicense: promisifyGetLicense(keySystemOptions.getLicense, eventBus), + getLicense: promisifyGetLicense(keySystem, keySystemOptions.getLicense, eventBus), removeSession, eventBus }); @@ -302,16 +332,22 @@ export const standard5July2016 = ({ } return keySystemPromise.then(() => { + let getLicenseFn; + + // addSession only needs getLicense if a key system has been determined + if (video.keySystem) { + getLicenseFn = standardizeKeySystemOptions(keySystem, + options.keySystems[keySystem]).getLicense; + // promisify the function + getLicenseFn = promisifyGetLicense(keySystem, getLicenseFn, eventBus); + } + return addSession({ video, initDataType, initData, options, - // if key system has not been determined then addSession doesn't need getLicense - getLicense: video.keySystem ? - promisifyGetLicense(standardizeKeySystemOptions( - video.keySystem, - options.keySystems[video.keySystem]).getLicense, eventBus) : null, + getLicense: getLicenseFn, removeSession, eventBus }); diff --git a/src/plugin.js b/src/plugin.js index a12c997..e0a610b 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -213,7 +213,19 @@ const onPlayerReady = (player, emeError) => { setupSessions(player); - if (window.WebKitMediaKeys) { + if (window.MediaKeys) { + // Support EME 05 July 2016 + // Chrome 42+, Firefox 47+, Edge + player.tech_.el_.addEventListener('encrypted', (event) => { + // TODO convert to videojs.log.debug and add back in + // https://github.com/videojs/video.js/pull/4780 + // videojs.log('eme', 'Received an \'encrypted\' event'); + setupSessions(player); + handleEncryptedEvent(event, getOptions(player), player.eme.sessions, player.tech_) + .catch(emeError); + }); + + } else if (window.WebKitMediaKeys) { // Support Safari EME with FairPlay // (also used in early Chrome or Chrome with EME disabled flag) player.tech_.el_.addEventListener('webkitneedkey', (event) => { @@ -228,18 +240,6 @@ const onPlayerReady = (player, emeError) => { .catch(emeError); }); - } else if (window.MediaKeys) { - // Support EME 05 July 2016 - // Chrome 42+, Firefox 47+, Edge, Safari 12.1+ on macOS 10.14+ - player.tech_.el_.addEventListener('encrypted', (event) => { - // TODO convert to videojs.log.debug and add back in - // https://github.com/videojs/video.js/pull/4780 - // videojs.log('eme', 'Received an \'encrypted\' event'); - setupSessions(player); - handleEncryptedEvent(event, getOptions(player), player.eme.sessions, player.tech_) - .catch(emeError); - }); - } else if (window.MSMediaKeys) { // IE11 Windows 8.1+ // Since IE11 doesn't support promises, we have to use a combination of @@ -314,7 +314,7 @@ const eme = function(options = {}) { setupSessions(player); - if (player.tech_.el_.setMediaKeys) { + if (window.MediaKeys) { handleEncryptedEvent(mockEncryptedEvent, mergedEmeOptions, player.eme.sessions, player.tech_) .then(() => callback()) .catch((error) => { @@ -323,7 +323,7 @@ const eme = function(options = {}) { emeError(error); } }); - } else if (player.tech_.el_.msSetMediaKeys) { + } else if (window.MSMediaKeys) { const msKeyHandler = (event) => { player.tech_.off('mskeyadded', msKeyHandler); player.tech_.off('mskeyerror', msKeyHandler); diff --git a/test/eme.test.js b/test/eme.test.js index c502700..ce08ee7 100644 --- a/test/eme.test.js +++ b/test/eme.test.js @@ -1,5 +1,6 @@ import QUnit from 'qunit'; import videojs from 'video.js'; +import window from 'global/window'; import { standard5July2016, makeNewRequest, @@ -505,7 +506,7 @@ if (!videojs.browser.IS_ANY_SAFARI) { }); } -QUnit.test('errors when neither url nor getLicense is given', function(assert) { +QUnit.test('errors when none of url, licenseUri, or getLicense is given', function(assert) { const options = { keySystems: { 'com.widevine.alpha': {} @@ -523,7 +524,32 @@ QUnit.test('errors when neither url nor getLicense is given', function(assert) { }).catch((err) => { assert.equal( err, - 'Error: Neither URL nor getLicense function provided to get license', + 'Error: Missing configuration: one of url, licenseUri, or getLicense is required', + 'correct error message' + ); + done(); + }); +}); + +QUnit.test('errors when neither certificateUri nor getCertificate is given for fairplay', function(assert) { + const options = { + keySystems: { + 'com.apple.fps': {url: 'fake-url'} + } + }; + const keySystemAccess = { + keySystem: 'com.apple.fps' + }; + const done = assert.async(1); + + standard5July2016({ + video: {}, + keySystemAccess, + options + }).catch((err) => { + assert.equal( + err, + 'Error: Missing configuration: one of certificateUri or getCertificate is required', 'correct error message' ); done(); @@ -888,3 +914,18 @@ QUnit.test('licenseHeaders keySystems property overrides emeHeaders value', func done(); }); }); + +QUnit.test('sets required fairplay defaults if not explicitly configured', function(assert) { + const origRequestMediaKeySystemAccess = window.navigator.requestMediaKeySystemAccess; + + window.navigator.requestMediaKeySystemAccess = (keySystem, systemOptions) => { + assert.ok(systemOptions[0].initDataTypes.indexOf('sinf') !== -1, + 'includes required initDataType'); + assert.ok(systemOptions[0].videoCapabilities[0].contentType.indexOf('video/mp4') !== -1, + 'includes required video contentType'); + }; + + getSupportedKeySystem({'com.apple.fps': {}}); + + window.requestMediaKeySystemAccess = origRequestMediaKeySystemAccess; +}); diff --git a/test/plugin.test.js b/test/plugin.test.js index 0fd20bd..dcfefc4 100644 --- a/test/plugin.test.js +++ b/test/plugin.test.js @@ -98,8 +98,8 @@ QUnit.test('exposes options', function(assert) { 'exposes publisherId'); }); -// skip test for Safari -if (!window.WebKitMediaKeys) { +// skip test for prefix-only Safari +if (window.MediaKeys) { QUnit.test('initializeMediaKeys standard', function(assert) { assert.expect(9); const done = assert.async(); @@ -119,7 +119,7 @@ if (!window.WebKitMediaKeys) { assert.deepEqual(sessions[0].initData, initData, 'captured initData in the session'); assert.equal( error, - 'Error: Neither URL nor getLicense function provided to get license', + 'Error: Missing configuration: one of url, licenseUri, or getLicense is required', 'callback receives error' ); }; @@ -131,7 +131,7 @@ if (!window.WebKitMediaKeys) { assert.equal(errors, 1, 'error triggered only once'); assert.equal( this.player.error().message, - 'Neither URL nor getLicense function provided to get license', + 'Missing configuration: one of url, licenseUri, or getLicense is required', 'error is called on player' ); this.player.error(null); @@ -268,7 +268,6 @@ QUnit.test('initializeMediaKeys ms-prefix', function(assert) { QUnit.test('tech error listener is removed on dispose', function(assert) { const done = assert.async(1); let called = 0; - const browser = videojs.browser; const origMediaKeys = window.MediaKeys; const origWebKitMediaKeys = window.WebKitMediaKeys; @@ -277,8 +276,6 @@ QUnit.test('tech error listener is removed on dispose', function(assert) { if (!window.MSMediaKeys) { window.MSMediaKeys = noop.bind(this); } - // let this test pass on edge - videojs.browser = {IS_EDGE: false}; this.player.error = () => { called++; @@ -297,7 +294,6 @@ QUnit.test('tech error listener is removed on dispose', function(assert) { assert.equal(called, 1, 'not called after player disposal'); this.player.error = undefined; - videojs.browser = browser; window.MediaKeys = origMediaKeys; window.WebKitMediaKeys = origWebKitMediaKeys; done(); @@ -306,6 +302,45 @@ QUnit.test('tech error listener is removed on dispose', function(assert) { this.clock.tick(1); }); +QUnit.test('only registers for spec-compliant events if legacy APIs are available', function(assert) { + const done = assert.async(1); + + const origMediaKeys = window.MediaKeys; + const origMSMediaKeys = window.MSMediaKeys; + const origWebKitMediaKeys = window.WebKitMediaKeys; + + const events = { + encrypted: 0, + msneedkey: 0, + webkitneedkey: 0 + }; + + this.player.tech_.el_ = { + addEventListener: e => events[e]++, + hasAttribute: () => false + }; + + window.MediaKeys = noop; + window.MSMediaKeys = noop; + window.WebKitMediaKeys = noop; + + this.player.eme(); + + this.player.ready(() => { + assert.equal(events.encrypted, 1, 'registers for encrypted events'); + assert.equal(events.msneedkey, 0, "doesn't register for msneedkey events"); + assert.equal(events.webkitneedkey, 0, "doesn't register for webkitneedkey events"); + + window.MediaKeys = origMediaKeys; + window.MSMediaKeys = origMSMediaKeys; + window.WebKitMediaKeys = origWebKitMediaKeys; + done(); + }); + + this.clock.tick(1); + +}); + QUnit.module('plugin guard functions', { beforeEach() { this.options = {