videojs-contrib-eme
Version:
Supports Encrypted Media Extensions for playback of encrypted content in Video.js
1,570 lines (1,398 loc) • 54.7 kB
JavaScript
/*! @name videojs-contrib-eme @version 5.5.2 @license Apache-2.0 */
import videojs from 'video.js';
import window from 'global/window';
import _extends from '@babel/runtime/helpers/extends';
import document from 'global/document';
const stringToUint16Array = string => {
// 2 bytes for each char
const buffer = new ArrayBuffer(string.length * 2);
const array = new Uint16Array(buffer);
for (let i = 0; i < string.length; i++) {
array[i] = string.charCodeAt(i);
}
return array;
};
const uint8ArrayToString = array => {
return String.fromCharCode.apply(null, new Uint8Array(array.buffer || array));
};
const uint16ArrayToString = array => {
return String.fromCharCode.apply(null, new Uint16Array(array.buffer || array));
};
const getHostnameFromUri = uri => {
const link = document.createElement('a');
link.href = uri;
return link.hostname;
};
const arrayBuffersEqual = (arrayBuffer1, arrayBuffer2) => {
if (arrayBuffer1 === arrayBuffer2) {
return true;
}
if (arrayBuffer1.byteLength !== arrayBuffer2.byteLength) {
return false;
}
const dataView1 = new DataView(arrayBuffer1);
const dataView2 = new DataView(arrayBuffer2);
for (let i = 0; i < dataView1.byteLength; i++) {
if (dataView1.getUint8(i) !== dataView2.getUint8(i)) {
return false;
}
}
return true;
};
const arrayBufferFrom = bufferOrTypedArray => {
if (bufferOrTypedArray instanceof Uint8Array || bufferOrTypedArray instanceof Uint16Array) {
return bufferOrTypedArray.buffer;
}
return bufferOrTypedArray;
}; // Normalize between Video.js 6/7 (videojs.mergeOptions) and 8 (videojs.obj.merge).
const merge = (...args) => {
const context = videojs.obj || videojs;
const fn = context.merge || context.mergeOptions;
return fn.apply(context, args);
};
const mergeAndRemoveNull = (...args) => {
const result = merge(...args); // Any header whose value is `null` will be removed.
Object.keys(result).forEach(k => {
if (result[k] === null) {
delete result[k];
}
});
return result;
};
/**
* Transforms the keySystems object into a MediaKeySystemConfiguration Object array.
*
* @param {Object} keySystems object from the options.
* @return {Array} of MediaKeySystemConfiguration objects.
*/
const getMediaKeySystemConfigurations = keySystems => {
const config = [];
Object.keys(keySystems).forEach(keySystem => {
const mediaKeySystemConfig = getSupportedConfigurations(keySystem, keySystems[keySystem])[0];
config.push(mediaKeySystemConfig);
});
return config;
};
let httpResponseHandler = videojs.xhr.httpHandler; // to make sure this doesn't break with older versions of Video.js,
// do a super simple wrapper instead
if (!httpResponseHandler) {
httpResponseHandler = (callback, decodeResponseBody) => (err, response, responseBody) => {
if (err) {
callback(err);
return;
} // if the HTTP status code is 4xx or 5xx, the request also failed
if (response.statusCode >= 400 && response.statusCode <= 599) {
let cause = responseBody;
if (decodeResponseBody) {
cause = String.fromCharCode.apply(null, new Uint8Array(responseBody));
}
callback({
cause
});
return;
} // otherwise, request succeeded
callback(null, responseBody);
};
}
/**
* Parses the EME key message XML to extract HTTP headers and the Challenge element to use
* in the PlayReady license request.
*
* @param {ArrayBuffer} message key message from EME
* @return {Object} an object containing headers and the message body to use in the
* license request
*/
const getMessageContents = message => {
// TODO do we want to support UTF-8?
const xmlString = String.fromCharCode.apply(null, new Uint16Array(message));
const xml = new window.DOMParser().parseFromString(xmlString, 'application/xml');
const headersElement = xml.getElementsByTagName('HttpHeaders')[0];
let headers = {};
if (headersElement) {
const headerNames = headersElement.getElementsByTagName('name');
const headerValues = headersElement.getElementsByTagName('value');
for (let i = 0; i < headerNames.length; i++) {
headers[headerNames[i].childNodes[0].nodeValue] = headerValues[i].childNodes[0].nodeValue;
}
}
const challengeElement = xml.getElementsByTagName('Challenge')[0];
let challenge;
if (challengeElement) {
challenge = window.atob(challengeElement.childNodes[0].nodeValue);
} // If we failed to parse the xml the soap message might be encoded already.
// set the message data as the challenge and add generic SOAP headers.
if (xml.querySelector('parsererror')) {
headers = {
'Content-Type': 'text/xml; charset=utf-8',
'SOAPAction': '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"'
};
challenge = message;
}
return {
headers,
message: challenge
};
};
const requestPlayreadyLicense = (keySystem, keySystemOptions, messageBuffer, emeOptions, callback) => {
const messageContents = getMessageContents(messageBuffer);
const message = messageContents.message;
const headers = mergeAndRemoveNull(messageContents.headers, emeOptions.emeHeaders, keySystemOptions.licenseHeaders);
videojs.xhr({
uri: keySystemOptions.url,
method: 'post',
headers,
body: message,
responseType: 'arraybuffer',
requestType: 'license',
metadata: {
keySystem
}
}, httpResponseHandler(callback, true));
};
const Error$1 = {
EMEFailedToRequestMediaKeySystemAccess: 'eme-failed-request-media-key-system-access',
EMEFailedToCreateMediaKeys: 'eme-failed-create-media-keys',
EMEFailedToAttachMediaKeysToVideoElement: 'eme-failed-attach-media-keys-to-video',
EMEFailedToCreateMediaKeySession: 'eme-failed-create-media-key-session',
EMEFailedToSetServerCertificate: 'eme-failed-set-server-certificate',
EMEFailedToGenerateLicenseRequest: 'eme-failed-generate-license-request',
EMEFailedToUpdateSessionWithReceivedLicenseKeys: 'eme-failed-update-session',
EMEFailedToCloseSession: 'eme-failed-close-session',
EMEFailedToRemoveKeysFromSession: 'eme-failed-remove-keys',
EMEFailedToLoadSessionBySessionId: 'eme-failed-load-session'
};
/**
* The W3C Working Draft of 22 October 2013 seems to be the best match for
* the ms-prefixed API. However, it should only be used as a guide; it is
* doubtful the spec is 100% implemented as described.
*
* @see https://www.w3.org/TR/2013/WD-encrypted-media-20131022
*/
const LEGACY_FAIRPLAY_KEY_SYSTEM = 'com.apple.fps.1_0';
const concatInitDataIdAndCertificate = ({
initData,
id,
cert
}) => {
if (typeof id === 'string') {
id = stringToUint16Array(id);
} // layout:
// [initData]
// [4 byte: idLength]
// [idLength byte: id]
// [4 byte:certLength]
// [certLength byte: cert]
let offset = 0;
const buffer = new ArrayBuffer(initData.byteLength + 4 + id.byteLength + 4 + cert.byteLength);
const dataView = new DataView(buffer);
const initDataArray = new Uint8Array(buffer, offset, initData.byteLength);
initDataArray.set(initData);
offset += initData.byteLength;
dataView.setUint32(offset, id.byteLength, true);
offset += 4;
const idArray = new Uint16Array(buffer, offset, id.length);
idArray.set(id);
offset += idArray.byteLength;
dataView.setUint32(offset, cert.byteLength, true);
offset += 4;
const certArray = new Uint8Array(buffer, offset, cert.byteLength);
certArray.set(cert);
return new Uint8Array(buffer, 0, buffer.byteLength);
};
const addKey = ({
video,
contentId,
initData,
cert,
options,
getLicense,
eventBus,
emeError
}) => {
return new Promise((resolve, reject) => {
if (!video.webkitKeys) {
try {
video.webkitSetMediaKeys(new window.WebKitMediaKeys(LEGACY_FAIRPLAY_KEY_SYSTEM));
} catch (error) {
const metadata = {
errorType: Error$1.EMEFailedToCreateMediaKeys,
keySystem: LEGACY_FAIRPLAY_KEY_SYSTEM
};
emeError(error, metadata);
reject('Could not create MediaKeys');
return;
}
}
let keySession;
try {
keySession = video.webkitKeys.createSession('video/mp4', concatInitDataIdAndCertificate({
id: contentId,
initData,
cert
}));
} catch (error) {
const metadata = {
errorType: Error$1.EMEFailedToCreateMediaKeySession,
keySystem: LEGACY_FAIRPLAY_KEY_SYSTEM
};
emeError(error, metadata);
reject('Could not create key session');
return;
}
safeTriggerOnEventBus(eventBus, {
type: 'keysessioncreated',
keySession
});
keySession.contentId = contentId;
keySession.addEventListener('webkitkeymessage', event => {
safeTriggerOnEventBus(eventBus, {
type: 'keymessage',
messageEvent: event
});
getLicense(options, contentId, event.message, (err, license) => {
if (eventBus) {
safeTriggerOnEventBus(eventBus, {
type: 'licenserequestattempted'
});
}
if (err) {
const metadata = {
errortype: Error$1.EMEFailedToGenerateLicenseRequest,
keySystem: LEGACY_FAIRPLAY_KEY_SYSTEM
};
emeError(err, metadata);
reject(err);
return;
}
keySession.update(new Uint8Array(license));
safeTriggerOnEventBus(eventBus, {
type: 'keysessionupdated',
keySession
});
});
});
keySession.addEventListener('webkitkeyadded', () => {
resolve();
}); // for testing purposes, adding webkitkeyerror must be the last item in this method
keySession.addEventListener('webkitkeyerror', () => {
const error = keySession.error;
const metadata = {
errorType: Error$1.EMEFailedToUpdateSessionWithReceivedLicenseKeys,
keySystem: LEGACY_FAIRPLAY_KEY_SYSTEM
};
emeError(error, metadata);
reject(`KeySession error: code ${error.code}, systemCode ${error.systemCode}`);
});
});
};
const defaultGetCertificate = (keySystem, fairplayOptions) => {
return (emeOptions, callback) => {
const headers = mergeAndRemoveNull(emeOptions.emeHeaders, fairplayOptions.certificateHeaders);
videojs.xhr({
uri: fairplayOptions.certificateUri,
responseType: 'arraybuffer',
requestType: 'license',
metadata: {
keySystem
},
headers
}, httpResponseHandler((err, license) => {
if (err) {
callback(err);
return;
} // in this case, license is still the raw ArrayBuffer,
// (we don't want httpResponseHandler to decode it)
// convert it into Uint8Array as expected
callback(null, new Uint8Array(license));
}));
};
};
const defaultGetContentId = (emeOptions, initDataString) => {
return getHostnameFromUri(initDataString);
};
const defaultGetLicense$1 = (keySystem, fairplayOptions) => {
return (emeOptions, contentId, keyMessage, callback) => {
const headers = mergeAndRemoveNull({
'Content-type': 'application/octet-stream'
}, emeOptions.emeHeaders, fairplayOptions.licenseHeaders);
videojs.xhr({
uri: fairplayOptions.licenseUri || fairplayOptions.url,
method: 'POST',
responseType: 'arraybuffer',
requestType: 'license',
metadata: {
keySystem,
contentId
},
body: keyMessage,
headers
}, httpResponseHandler(callback, true));
};
};
const fairplay = ({
video,
initData,
options,
eventBus,
emeError
}) => {
const fairplayOptions = options.keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM];
const getCertificate = fairplayOptions.getCertificate || defaultGetCertificate(LEGACY_FAIRPLAY_KEY_SYSTEM, fairplayOptions);
const getContentId = fairplayOptions.getContentId || defaultGetContentId;
const getLicense = fairplayOptions.getLicense || defaultGetLicense$1(LEGACY_FAIRPLAY_KEY_SYSTEM, fairplayOptions);
return new Promise((resolve, reject) => {
getCertificate(options, (err, cert) => {
if (err) {
const metadata = {
errorType: Error$1.EMEFailedToSetServerCertificate,
keySystem: LEGACY_FAIRPLAY_KEY_SYSTEM
};
emeError(err, metadata);
reject(err);
return;
}
resolve(cert);
});
}).then(cert => {
return addKey({
video,
cert,
initData,
getLicense,
options,
contentId: getContentId(options, uint16ArrayToString(initData)),
eventBus,
emeError
});
});
};
const isFairplayKeySystem = str => str.startsWith('com.apple.fps');
/**
* Trigger an event on the event bus component safely.
*
* This is used because there are cases where we can see race conditions
* between asynchronous operations (like closing a key session) and the
* availability of the event bus's DOM element.
*
* @param {Component} eventBus
* @param {...} args
*/
const safeTriggerOnEventBus = (eventBus, args) => {
if (eventBus.isDisposed()) {
return;
}
eventBus.trigger(_extends({}, args));
};
/**
* Returns an array of MediaKeySystemConfigurationObjects provided in the keySystem
* options.
*
* @see {@link https://www.w3.org/TR/encrypted-media/#dom-mediakeysystemconfiguration|MediaKeySystemConfigurationObject}
*
* @param {Object} keySystemOptions
* Options passed into videojs-contrib-eme for a specific keySystem
* @return {Object[]}
* Array of MediaKeySystemConfigurationObjects
*/
const getSupportedConfigurations = (keySystem, keySystemOptions) => {
if (keySystemOptions.supportedConfigurations) {
return keySystemOptions.supportedConfigurations;
}
const isFairplay = isFairplayKeySystem(keySystem);
const supportedConfiguration = {};
const initDataTypes = keySystemOptions.initDataTypes || ( // fairplay requires an explicit initDataTypes
isFairplay ? ['sinf'] : null);
const audioContentType = keySystemOptions.audioContentType;
const audioRobustness = keySystemOptions.audioRobustness;
const videoContentType = keySystemOptions.videoContentType || ( // fairplay requires an explicit videoCapabilities/videoContentType
isFairplay ? 'video/mp4' : null);
const videoRobustness = keySystemOptions.videoRobustness;
const persistentState = keySystemOptions.persistentState;
if (audioContentType || audioRobustness) {
supportedConfiguration.audioCapabilities = [_extends({}, audioContentType ? {
contentType: audioContentType
} : {}, audioRobustness ? {
robustness: audioRobustness
} : {})];
}
if (videoContentType || videoRobustness) {
supportedConfiguration.videoCapabilities = [_extends({}, videoContentType ? {
contentType: videoContentType
} : {}, videoRobustness ? {
robustness: videoRobustness
} : {})];
}
if (persistentState) {
supportedConfiguration.persistentState = persistentState;
}
if (initDataTypes) {
supportedConfiguration.initDataTypes = initDataTypes;
}
return [supportedConfiguration];
};
const getSupportedKeySystem = keySystems => {
// As this happens after the src is set on the video, we rely only on the set src (we
// do not change src based on capabilities of the browser in this plugin).
let promise;
Object.keys(keySystems).forEach(keySystem => {
const supportedConfigurations = getSupportedConfigurations(keySystem, keySystems[keySystem]);
if (!promise) {
promise = window.navigator.requestMediaKeySystemAccess(keySystem, supportedConfigurations);
} else {
promise = promise.catch(e => window.navigator.requestMediaKeySystemAccess(keySystem, supportedConfigurations));
}
});
return promise;
};
const makeNewRequest = (player, requestOptions) => {
const {
mediaKeys,
initDataType,
initData,
options,
getLicense,
removeSession,
eventBus,
contentId,
emeError,
keySystem
} = requestOptions;
let timeElapsed = 0;
let pauseTimer;
player.on('pause', () => {
if (options.limitRenewalsMaxPauseDuration && typeof options.limitRenewalsMaxPauseDuration === 'number') {
pauseTimer = setInterval(() => {
timeElapsed++;
if (timeElapsed >= options.limitRenewalsMaxPauseDuration) {
clearInterval(pauseTimer);
}
}, 1000);
player.on('play', () => {
clearInterval(pauseTimer);
timeElapsed = 0;
});
}
});
try {
const keySession = mediaKeys.createSession();
const closeAndRemoveSession = () => {
videojs.log.debug('Session expired, closing the session.');
keySession.close().then(() => {
// Because close() is async, this promise could resolve after the
// player has been disposed.
if (eventBus.isDisposed()) {
return;
}
safeTriggerOnEventBus(eventBus, {
type: 'keysessionclosed',
keySession
});
removeSession(initData);
}).catch(error => {
const metadata = {
errorType: Error$1.EMEFailedToCloseSession,
keySystem
};
emeError(error, metadata);
});
};
safeTriggerOnEventBus(eventBus, {
type: 'keysessioncreated',
keySession
});
player.on('dispose', () => {
closeAndRemoveSession();
});
return new Promise((resolve, reject) => {
keySession.addEventListener('message', event => {
safeTriggerOnEventBus(eventBus, {
type: 'keymessage',
messageEvent: event
}); // all other types will be handled by keystatuseschange
if (event.messageType !== 'license-request' && event.messageType !== 'license-renewal') {
return;
}
if (event.messageType === 'license-renewal') {
const limitRenewalsBeforePlay = options.limitRenewalsBeforePlay;
const limitRenewalsMaxPauseDuration = options.limitRenewalsMaxPauseDuration;
const validLimitRenewalsMaxPauseDuration = typeof limitRenewalsMaxPauseDuration === 'number';
const renewingBeforePlayback = !player.hasStarted() && limitRenewalsBeforePlay;
const maxPauseDurationReached = player.paused() && validLimitRenewalsMaxPauseDuration && timeElapsed >= limitRenewalsMaxPauseDuration;
const ended = player.ended();
if (renewingBeforePlayback || maxPauseDurationReached || ended) {
closeAndRemoveSession();
return;
}
}
getLicense(options, event.message, contentId).then(license => {
resolve(keySession.update(license).then(() => {
safeTriggerOnEventBus(eventBus, {
type: 'keysessionupdated',
keySession
});
}).catch(error => {
const metadata = {
errorType: Error$1.EMEFailedToUpdateSessionWithReceivedLicenseKeys,
keySystem
};
emeError(error, metadata);
}));
}).catch(err => {
reject(err);
});
}, false);
const KEY_STATUSES_CHANGE = 'keystatuseschange';
keySession.addEventListener(KEY_STATUSES_CHANGE, event => {
let expired = false; // Protect from race conditions causing the player to be disposed.
if (eventBus.isDisposed()) {
return;
} // Re-emit the keystatuseschange event with the entire keyStatusesMap
safeTriggerOnEventBus(eventBus, {
type: KEY_STATUSES_CHANGE,
keyStatuses: keySession.keyStatuses
}); // Keep 'keystatuschange' for backward compatibility.
// based on https://www.w3.org/TR/encrypted-media/#example-using-all-events
keySession.keyStatuses.forEach((status, keyId) => {
// Trigger an event so that outside listeners can take action if appropriate.
// For instance, the `output-restricted` status should result in an
// error being thrown.
safeTriggerOnEventBus(eventBus, {
keyId,
status,
target: keySession,
type: 'keystatuschange'
});
switch (status) {
case 'expired':
// If one key is expired in a session, all keys are expired. From
// https://www.w3.org/TR/encrypted-media/#dom-mediakeystatus-expired, "All other
// keys in the session must have this status."
expired = true;
break;
case 'internal-error':
const message = 'Key status reported as "internal-error." Leaving the session open since we ' + 'don\'t have enough details to know if this error is fatal.'; // "This value is not actionable by the application."
// https://www.w3.org/TR/encrypted-media/#dom-mediakeystatus-internal-error
videojs.log.warn(message, event);
break;
}
});
if (expired) {
// Close session and remove it from the session list to ensure that a new
// session can be created.
closeAndRemoveSession();
}
}, false);
keySession.generateRequest(initDataType, initData).catch(error => {
const metadata = {
errorType: Error$1.EMEFailedToGenerateLicenseRequest,
keySystem
};
emeError(error, metadata);
reject('Unable to create or initialize key session');
});
});
} catch (error) {
const metadata = {
errorType: Error$1.EMEFailedToCreateMediaKeySession,
keySystem
};
emeError(error, metadata);
}
};
/*
* Creates a new media key session if media keys are available, otherwise queues the
* session creation for when the media keys are available.
*
* @see {@link https://www.w3.org/TR/encrypted-media/#dom-mediakeysession|MediaKeySession}
* @see {@link https://www.w3.org/TR/encrypted-media/#dom-mediakeys|MediaKeys}
*
* @function addSession
* @param {Object} video
* Target video element
* @param {string} initDataType
* The type of init data provided
* @param {Uint8Array} initData
* The media's init data
* @param {Object} options
* Options provided to the plugin for this key system
* @param {function()} [getLicense]
* User provided function to retrieve a license
* @param {function()} removeSession
* Function to remove the persisted session on key expiration so that a new session
* may be created
* @param {Object} eventBus
* Event bus for any events pertinent to users
* @return {Promise}
* A resolved promise if session is waiting for media keys, or a promise for the
* session creation if media keys are available
*/
const addSession = ({
player,
video,
initDataType,
initData,
options,
getLicense,
contentId,
removeSession,
eventBus,
emeError
}) => {
const sessionData = {
initDataType,
initData,
options,
getLicense,
removeSession,
eventBus,
contentId,
emeError,
keySystem: video.keySystem
};
if (video.mediaKeysObject) {
sessionData.mediaKeys = video.mediaKeysObject;
return makeNewRequest(player, sessionData);
}
video.pendingSessionData.push(sessionData);
return Promise.resolve();
};
/*
* Given media keys created from a key system access object, check for any session data
* that was queued and create new sessions for each.
*
* @see {@link https://www.w3.org/TR/encrypted-media/#dom-mediakeysystemaccess|MediaKeySystemAccess}
* @see {@link https://www.w3.org/TR/encrypted-media/#dom-mediakeysession|MediaKeySession}
* @see {@link https://www.w3.org/TR/encrypted-media/#dom-mediakeys|MediaKeys}
*
* @function addPendingSessions
* @param {Object} video
* Target video element
* @param {string} [certificate]
* The server certificate (if used)
* @param {Object} createdMediaKeys
* Media keys to use for session creation
* @return {Promise}
* A promise containing new session creations and setting of media keys on the
* video object
*/
const addPendingSessions = ({
player,
video,
certificate,
createdMediaKeys,
emeError
}) => {
// save media keys on the video element to act as a reference for other functions so
// that they don't recreate the keys
video.mediaKeysObject = createdMediaKeys;
const promises = [];
if (certificate) {
promises.push(createdMediaKeys.setServerCertificate(certificate).catch(error => {
const metadata = {
errorType: Error$1.EMEFailedToSetServerCertificate,
keySystem: video.keySystem
};
emeError(error, metadata);
}));
}
for (let i = 0; i < video.pendingSessionData.length; i++) {
const data = video.pendingSessionData[i];
promises.push(makeNewRequest(player, {
mediaKeys: video.mediaKeysObject,
initDataType: data.initDataType,
initData: data.initData,
options: data.options,
getLicense: data.getLicense,
removeSession: data.removeSession,
eventBus: data.eventBus,
contentId: data.contentId,
emeError: data.emeError,
keySystem: video.keySystem
}));
}
video.pendingSessionData = [];
promises.push(video.setMediaKeys(createdMediaKeys).catch(error => {
const metadata = {
errorType: Error$1.EMEFailedToAttachMediaKeysToVideoElement,
keySystem: video.keySystem
};
emeError(error, metadata);
}));
return Promise.all(promises);
};
const defaultPlayreadyGetLicense = (keySystem, keySystemOptions) => (emeOptions, keyMessage, callback) => {
requestPlayreadyLicense(keySystem, keySystemOptions, keyMessage, emeOptions, callback);
};
const defaultGetLicense = (keySystem, keySystemOptions) => (emeOptions, keyMessage, callback) => {
const headers = mergeAndRemoveNull({
'Content-type': 'application/octet-stream'
}, emeOptions.emeHeaders, keySystemOptions.licenseHeaders);
videojs.xhr({
uri: keySystemOptions.url,
method: 'POST',
responseType: 'arraybuffer',
requestType: 'license',
metadata: {
keySystem
},
body: keyMessage,
headers
}, httpResponseHandler(callback, true));
};
const promisifyGetLicense = (keySystem, getLicenseFn, eventBus) => {
return (emeOptions, keyMessage, contentId) => {
return new Promise((resolve, reject) => {
const callback = function (err, license) {
if (eventBus) {
safeTriggerOnEventBus(eventBus, {
type: 'licenserequestattempted'
});
}
if (err) {
reject(err);
return;
}
resolve(license);
};
if (isFairplayKeySystem(keySystem)) {
getLicenseFn(emeOptions, contentId, new Uint8Array(keyMessage), callback);
} else {
getLicenseFn(emeOptions, keyMessage, callback);
}
});
};
};
const standardizeKeySystemOptions = (keySystem, keySystemOptions) => {
if (typeof keySystemOptions === 'string') {
keySystemOptions = {
url: keySystemOptions
};
}
if (!keySystemOptions.url && keySystemOptions.licenseUri) {
keySystemOptions.url = keySystemOptions.licenseUri;
}
if (!keySystemOptions.url && !keySystemOptions.getLicense) {
throw new Error(`Missing url/licenseUri or getLicense in ${keySystem} keySystem configuration.`);
}
const isFairplay = isFairplayKeySystem(keySystem);
if (isFairplay && keySystemOptions.certificateUri && !keySystemOptions.getCertificate) {
keySystemOptions.getCertificate = defaultGetCertificate(keySystem, keySystemOptions);
}
if (isFairplay && !keySystemOptions.getCertificate) {
throw new Error(`Missing getCertificate or certificateUri in ${keySystem} keySystem configuration.`);
}
if (isFairplay && !keySystemOptions.getContentId) {
keySystemOptions.getContentId = defaultGetContentId;
}
if (keySystemOptions.url && !keySystemOptions.getLicense) {
if (keySystem === 'com.microsoft.playready') {
keySystemOptions.getLicense = defaultPlayreadyGetLicense(keySystem, keySystemOptions);
} else if (isFairplay) {
keySystemOptions.getLicense = defaultGetLicense$1(keySystem, keySystemOptions);
} else {
keySystemOptions.getLicense = defaultGetLicense(keySystem, keySystemOptions);
}
}
return keySystemOptions;
};
const standard5July2016 = ({
player,
video,
initDataType,
initData,
keySystemAccess,
options,
removeSession,
eventBus,
emeError
}) => {
let keySystemPromise = Promise.resolve();
const keySystem = keySystemAccess.keySystem;
let keySystemOptions; // try catch so that we return a promise rejection
try {
keySystemOptions = standardizeKeySystemOptions(keySystem, options.keySystems[keySystem]);
} catch (e) {
return Promise.reject(e);
}
const contentId = keySystemOptions.getContentId ? keySystemOptions.getContentId(options, uint8ArrayToString(initData)) : null;
if (typeof video.mediaKeysObject === 'undefined') {
// Prevent entering this path again.
video.mediaKeysObject = null; // Will store all initData until the MediaKeys is ready.
video.pendingSessionData = [];
let certificate;
keySystemPromise = new Promise((resolve, reject) => {
// save key system for adding sessions
video.keySystem = keySystem;
if (!keySystemOptions.getCertificate) {
resolve(keySystemAccess);
return;
}
keySystemOptions.getCertificate(options, (err, cert) => {
if (err) {
reject(err);
return;
}
certificate = cert;
resolve();
});
}).then(() => {
return keySystemAccess.createMediaKeys();
}).then(createdMediaKeys => {
safeTriggerOnEventBus(eventBus, {
type: 'keysystemaccesscomplete',
mediaKeys: createdMediaKeys
});
return addPendingSessions({
player,
video,
certificate,
createdMediaKeys,
emeError
});
}).catch(err => {
const metadata = {
errorType: Error$1.EMEFailedToCreateMediaKeys,
keySystem
};
emeError(err, metadata); // if we have a specific error message, use it, otherwise show a more
// generic one
if (err) {
return Promise.reject(err);
}
return Promise.reject('Failed to create and initialize a MediaKeys object');
});
}
return keySystemPromise.then(() => {
// if key system has not been determined then addSession doesn't need getLicense
const getLicense = video.keySystem ? promisifyGetLicense(keySystem, keySystemOptions.getLicense, eventBus) : null;
return addSession({
player,
video,
initDataType,
initData,
options,
getLicense,
contentId,
removeSession,
eventBus,
emeError
});
});
};
/**
* The W3C Working Draft of 22 October 2013 seems to be the best match for
* the ms-prefixed API. However, it should only be used as a guide; it is
* doubtful the spec is 100% implemented as described.
*
* @see https://www.w3.org/TR/2013/WD-encrypted-media-20131022
* @see https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/compatibility/mt598601(v=vs.85)
*/
const PLAYREADY_KEY_SYSTEM = 'com.microsoft.playready';
const addKeyToSession = (options, session, event, eventBus, emeError) => {
let playreadyOptions = options.keySystems[PLAYREADY_KEY_SYSTEM];
if (typeof playreadyOptions.getKey === 'function') {
playreadyOptions.getKey(options, event.destinationURL, event.message.buffer, (err, key) => {
if (err) {
const metadata = {
errorType: Error$1.EMEFailedToRequestMediaKeySystemAccess,
config: getMediaKeySystemConfigurations(options.keySystems)
};
emeError(err, metadata);
safeTriggerOnEventBus(eventBus, {
message: 'Unable to get key: ' + err,
target: session,
type: 'mskeyerror'
});
return;
}
session.update(key);
safeTriggerOnEventBus(eventBus, {
type: 'keysessionupdated',
keySession: session
});
});
return;
}
if (typeof playreadyOptions === 'string') {
playreadyOptions = {
url: playreadyOptions
};
} else if (typeof playreadyOptions === 'boolean') {
playreadyOptions = {};
}
if (!playreadyOptions.url) {
playreadyOptions.url = event.destinationURL;
}
const callback = (err, responseBody) => {
if (eventBus) {
safeTriggerOnEventBus(eventBus, {
type: 'licenserequestattempted'
});
}
if (err) {
const metadata = {
errorType: Error$1.EMEFailedToGenerateLicenseRequest,
keySystem: PLAYREADY_KEY_SYSTEM
};
emeError(err, metadata);
safeTriggerOnEventBus(eventBus, {
message: 'Unable to request key from url: ' + playreadyOptions.url,
target: session,
type: 'mskeyerror'
});
return;
}
session.update(new Uint8Array(responseBody));
};
if (playreadyOptions.getLicense) {
playreadyOptions.getLicense(options, event.message.buffer, callback);
} else {
requestPlayreadyLicense(PLAYREADY_KEY_SYSTEM, playreadyOptions, event.message.buffer, options, callback);
}
};
const createSession = (video, initData, options, eventBus, emeError) => {
// Note: invalid mime type passed here throws a NotSupportedError
const session = video.msKeys.createSession('video/mp4', initData);
if (!session) {
const error = new Error('Could not create key session.');
const metadata = {
errorType: Error$1.EMEFailedToCreateMediaKeySession,
keySystem: PLAYREADY_KEY_SYSTEM
};
emeError(error, metadata);
throw error;
}
safeTriggerOnEventBus(eventBus, {
type: 'keysessioncreated',
keySession: session
}); // Note that mskeymessage may not always be called for PlayReady:
//
// "If initData contains a PlayReady object that contains an OnDemand header, only a
// keyAdded event is returned (as opposed to a keyMessage event as described in the
// Encrypted Media Extension draft). Similarly, if initData contains a PlayReady object
// that contains a key identifier in the hashed data storage (HDS), only a keyAdded
// event is returned."
// eslint-disable-next-line max-len
// @see [PlayReady License Acquisition]{@link https://msdn.microsoft.com/en-us/library/dn468979.aspx}
session.addEventListener('mskeymessage', event => {
safeTriggerOnEventBus(eventBus, {
type: 'keymessage',
messageEvent: event
});
addKeyToSession(options, session, event, eventBus, emeError);
});
session.addEventListener('mskeyerror', event => {
const metadata = {
errorType: Error$1.EMEFailedToCreateMediaKeySession,
keySystem: PLAYREADY_KEY_SYSTEM
};
emeError(session.error, metadata);
safeTriggerOnEventBus(eventBus, {
message: 'Unexpected key error from key session with ' + `code: ${session.error.code} and systemCode: ${session.error.systemCode}`,
target: session,
type: 'mskeyerror'
});
});
session.addEventListener('mskeyadded', () => {
safeTriggerOnEventBus(eventBus, {
target: session,
type: 'mskeyadded'
});
});
};
var msPrefixed = (({
video,
initData,
options,
eventBus,
emeError
}) => {
// Although by the standard examples the presence of video.msKeys is checked first to
// verify that we aren't trying to create a new session when one already exists, here
// sessions are managed earlier (on the player.eme object), meaning that at this point
// any existing keys should be cleaned up.
// TODO: Will this break rotation? Is it safe?
if (video.msKeys) {
delete video.msKeys;
}
try {
video.msSetMediaKeys(new window.MSMediaKeys(PLAYREADY_KEY_SYSTEM));
} catch (e) {
const metadata = {
errorType: Error$1.EMEFailedToCreateMediaKeys,
keySystem: PLAYREADY_KEY_SYSTEM
};
emeError(e, metadata);
throw new Error('Unable to create media keys for PlayReady key system. ' + 'Error: ' + e.message);
}
createSession(video, initData, options, eventBus, emeError);
});
const genericConfig = [{
initDataTypes: ['cenc'],
audioCapabilities: [{
contentType: 'audio/mp4;codecs="mp4a.40.2"'
}],
videoCapabilities: [{
contentType: 'video/mp4;codecs="avc1.42E01E"'
}]
}];
const keySystems = [// Fairplay
// Requires different config than other CDMs
{
keySystem: 'com.apple.fps',
supportedConfig: [{
initDataTypes: ['sinf'],
videoCapabilities: [{
contentType: 'video/mp4'
}]
}]
}, // Playready
{
keySystem: 'com.microsoft.playready.recommendation',
supportedConfig: genericConfig
}, // Widevine
{
keySystem: 'com.widevine.alpha',
supportedConfig: genericConfig
}, // Clear
{
keySystem: 'org.w3.clearkey',
supportedConfig: genericConfig
}]; // Asynchronously detect the list of supported CDMs by requesting key system access
// when possible, otherwise rely on browser-specific EME API feature detection.
const detectSupportedCDMs = () => {
const Promise = window.Promise;
const results = {
fairplay: Boolean(window.WebKitMediaKeys),
playready: false,
widevine: false,
clearkey: false
};
if (!window.MediaKeys || !window.navigator.requestMediaKeySystemAccess) {
return Promise.resolve(results);
}
return Promise.all(keySystems.map(({
keySystem,
supportedConfig
}) => {
return window.navigator.requestMediaKeySystemAccess(keySystem, supportedConfig).catch(() => {});
})).then(([fairplay, playready, widevine, clearkey]) => {
results.fairplay = Boolean(fairplay);
results.playready = Boolean(playready);
results.widevine = Boolean(widevine);
results.clearkey = Boolean(clearkey);
return results;
});
};
var version = "5.5.2";
const hasSession = (sessions, initData) => {
for (let i = 0; i < sessions.length; i++) {
// Other types of sessions may be in the sessions array that don't store the initData
// (for instance, PlayReady sessions on IE11).
if (!sessions[i].initData) {
continue;
} // initData should be an ArrayBuffer by the spec:
// eslint-disable-next-line max-len
// @see [Media Encrypted Event initData Spec]{@link https://www.w3.org/TR/encrypted-media/#mediaencryptedeventinit}
//
// However, on some browsers it may come back with a typed array view of the buffer.
// This is the case for IE11, however, since IE11 sessions are handled differently
// (following the msneedkey PlayReady path), this coversion may not be important. It
// is safe though, and might be a good idea to retain in the short term (until we have
// catalogued the full range of browsers and their implementations).
const sessionBuffer = arrayBufferFrom(sessions[i].initData);
const initDataBuffer = arrayBufferFrom(initData);
if (arrayBuffersEqual(sessionBuffer, initDataBuffer)) {
return true;
}
}
return false;
};
const removeSession = (sessions, initData) => {
for (let i = 0; i < sessions.length; i++) {
if (sessions[i].initData === initData) {
sessions.splice(i, 1);
return;
}
}
};
function handleEncryptedEvent(player, event, options, sessions, eventBus, emeError) {
if (!options || !options.keySystems) {
// return silently since it may be handled by a different system
return Promise.resolve();
} // Legacy fairplay is the keysystem 'com.apple.fps.1_0'.
// If we are using this keysystem we want to use WebkitMediaKeys.
// This can be initialized manually with initLegacyFairplay().
if (options.keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM] && window.WebKitMediaKeys && player.eme.legacyFairplayIsUsed) {
videojs.log.debug('eme', `Ignoring \'encrypted\' event, using legacy fairplay keySystem ${LEGACY_FAIRPLAY_KEY_SYSTEM}`);
return Promise.resolve();
}
let initData = event.initData;
return getSupportedKeySystem(options.keySystems).then(keySystemAccess => {
const keySystem = keySystemAccess.keySystem; // Use existing init data from options if provided
if (options.keySystems[keySystem] && options.keySystems[keySystem].pssh) {
initData = options.keySystems[keySystem].pssh;
} // "Initialization Data must be a fixed value for a given set of stream(s) or media
// data. It must only contain information related to the keys required to play a given
// set of stream(s) or media data."
// eslint-disable-next-line max-len
// @see [Initialization Data Spec]{@link https://www.w3.org/TR/encrypted-media/#initialization-data}
if (hasSession(sessions, initData) || !initData) {
// TODO convert to videojs.log.debug and add back in
// https://github.com/videojs/video.js/pull/4780
// videojs.log('eme',
// 'Already have a configured session for init data, ignoring event.');
return Promise.resolve();
}
sessions.push({
initData
});
return standard5July2016({
player,
video: event.target,
initDataType: event.initDataType,
initData,
keySystemAccess,
options,
removeSession: removeSession.bind(null, sessions),
eventBus,
emeError
});
}).catch(error => {
const metadata = {
errorType: Error$1.EMEFailedToRequestMediaKeySystemAccess,
config: getMediaKeySystemConfigurations(options.keySystems)
};
emeError(error, metadata);
});
}
const handleWebKitNeedKeyEvent = (event, options, eventBus, emeError) => {
if (!options.keySystems || !options.keySystems[LEGACY_FAIRPLAY_KEY_SYSTEM] || !event.initData) {
// return silently since it may be handled by a different system
return Promise.resolve();
} // From Apple's example Safari FairPlay integration code, webkitneedkey is not repeated
// for the same content. Unless documentation is found to present the opposite, handle
// all webkitneedkey events the same (even if they are repeated).
return fairplay({
video: event.target,
initData: event.initData,
options,
eventBus,
emeError
});
};
const handleMsNeedKeyEvent = (event, options, sessions, eventBus, emeError) => {
if (!options.keySystems || !options.keySystems[PLAYREADY_KEY_SYSTEM]) {
// return silently since it may be handled by a different system
return;
} // "With PlayReady content protection, your Web app must handle the first needKey event,
// but it must then ignore any other needKey event that occurs."
// eslint-disable-next-line max-len
// @see [PlayReady License Acquisition]{@link https://msdn.microsoft.com/en-us/library/dn468979.aspx}
//
// Usually (and as per the example in the link above) this is determined by checking for
// the existence of video.msKeys. However, since the video element may be reused, it's
// easier to directly manage the session.
if (sessions.reduce((acc, session) => acc || session.playready, false)) {
// TODO convert to videojs.log.debug and add back in
// https://github.com/videojs/video.js/pull/4780
// videojs.log('eme',
// 'An \'msneedkey\' event was receieved earlier, ignoring event.');
return;
}
let initData = event.initData; // Use existing init data from options if provided
if (options.keySystems[PLAYREADY_KEY_SYSTEM] && options.keySystems[PLAYREADY_KEY_SYSTEM].pssh) {
initData = options.keySystems[PLAYREADY_KEY_SYSTEM].pssh;
}
if (!initData) {
return;
}
sessions.push({
playready: true,
initData
});
msPrefixed({
video: event.target,
initData,
options,
eventBus,
emeError
});
};
const getOptions = player => {
return merge(player.currentSource(), player.eme.options);
};
/**
* Configure a persistent sessions array and activeSrc property to ensure we properly
* handle each independent source's events. Should be run on any encrypted or needkey
* style event to ensure that the sessions reflect the active source.
*
* @function setupSessions
* @param {Player} player
*/
const setupSessions = player => {
const src = player.src();
if (src !== player.eme.activeSrc) {
player.eme.activeSrc = src;
player.eme.sessions = [];
}
};
/**
* Construct a simple function that can be used to dispatch EME errors on the
* player directly, such as providing it to a `.catch()`.
*
* @function emeErrorHandler
* @param {Player} player
* @return {Function}
*/
const emeErrorHandler = player => {
return (objOrErr, metadata) => {
const error = {
// MEDIA_ERR_ENCRYPTED is code 5
code: 5
};
if (typeof objOrErr === 'string') {
error.message = objOrErr;
} else if (objOrErr) {
if (objOrErr.message) {
error.message = objOrErr.message;
}
if (objOrErr.cause && (objOrErr.cause.length || objOrErr.cause.byteLength)) {
error.cause = objOrErr.cause;
}
if (objOrErr.keySystem) {
error.keySystem = objOrErr.keySystem;
} // pass along original error object.
error.originalError = objOrErr;
}
if (metadata) {
error.metadata = metadata;
}
player.error(error);
};
};
/**
* Function to invoke when the player is ready.
*
* This is a great place for your plugin to initialize itself. When this
* function is called, the player will have its DOM and child components
* in place.
*
* @function onPlayerReady
* @param {Player} player
* @param {Function} emeError
*/
const onPlayerReady = (player, emeError) => {
if (player.$('.vjs-tech').tagName.toLowerCase() !== 'video') {
return;
}
setupSessions(player);
if (window.MediaKeys) {
const sendMockEncryptedEvent = () => {
const mockEncryptedEvent = {
initDataType: 'cenc',
initData: null,
target: player.tech_.el_
};
setupSessions(player);
handleEncryptedEvent(player, mockEncryptedEvent, getOptions(player), player.eme.sessions, player.tech_, emeError);
};
if (videojs.browser.IS_FIREFOX) {
// Unlike Chrome, Firefox doesn't receive an `encrypted` event on
// replay and seek-back after content ends and `handleEncryptedEvent` is never called.
// So a fake encrypted event is necessary here.
let handled;
player.on('ended', () => {
handled = false;
player.one(['seek', 'play'], e => {
if (!handled && player.eme.sessions.length === 0) {
sendMockEncryptedEvent();
handled = true;
}
});
});
player.on('play', () => {
const options = player.eme.options;
const limitRenewalsMaxPauseDuration = options.limitRenewalsMaxPauseDuration;
if (player.eme.sessions.length === 0 && typeof limitRenewalsMaxPauseDuration === 'number') {
handled = true;
sendMockEncryptedEvent();
}
});
} // Support EME 05 July 2016
// Chrome 42+, Firefox 47+, Edge, Safari 12.1+ on macOS 10.14+
player.tech_.el_.addEventListener('encrypted', event => {
videojs.log.debug('eme', 'Received an \'encrypted\' event');
setupSessions(player);
handleEncryptedEvent(player, event, getOptions(player), player.eme.sessions, player.tech_, emeError);
});
} else if (window.WebKitMediaKeys) {
player.eme.initLegacyFairplay();
} else if (window.MSMediaKeys) {
// IE11 Windows 8.1+
// Since IE11 doesn't support promises, we have to use a combination of
// try/catch blocks and event handling to simulate promise rejection.
// Functionally speaking, there should be no discernible difference between
// the behavior of IE11 and those of other browsers.
player.tech_.el_.addEventListener('msneedkey', event => {
videojs.log.debug('eme', 'Received an \'msneedkey\' event');
setupSessions(player);
try {
handleMsNeedKeyEvent(event, getOptions(player), player.eme.sessions, player.tech_, emeError);
} catch (error) {
emeError(error);
}
});
const msKeyErrorCallback = error => {
emeError(error);
};
player.tech_.on('mskeyerror', msKeyErrorCallback); // TODO: refactor this plugin so it can use a plugin dispose
player.on('dispose', () => {
player.tech_.off('mskeyerror', msKeyErrorCallback);
});
}
};
/**
* A video.js plugin.
*
* In the plugin function, the value of `this` is a video.js `Player`
* instance. You cannot rely on the player being in a "ready" state here,
* depending on how the plugin is invoked. This may or may not be important
* to you; if not, remove the wait for "ready"!
*
* @function eme
* @param {Object} [options={}]
* An object of options left to the plugin author to define.
*/
const eme = function (options = {}) {
const player = this;
const emeError = emeErrorHandler(player);
player.ready(() => onPlayerReady(player, emeError)); // Plugin API
player.eme = {
/**
* For manual setup for eme listeners (for example: after player.reset call)
* basically for any cases when player.tech.el is changed
*/
setupEmeListeners() {
onPlayerReady(player, emeError);
},
/**
* Sets up MediaKeys on demand
* Works around https://bugs.chromium.org/p/chromium/issues/detail?id=895449
*
* @function initializeMediaKeys
* @param {Object} [emeOptions={}]
* An object of eme plugin options.
* @param {Function} [callback=function(){}]
* @param {boolean} [suppressErrorIfPossible=false]
*/
initializeMediaKeys(emeOptions = {}, callback = function () {}, suppressErrorIfPossible = false) {
// TODO: this should be refactored and renamed to be less tied
// to encrypted events
const mergedEmeOptions = merge(player.currentSource(), options, emeOptions); // fake an encrypted event for handleEncryptedEvent
const mockEncryptedEvent = {
initDataType: 'cenc',
initData: null,
target: player.tech_.el_
};
setupSessions(pla