videojs-contrib-eme
Version:
Supports Encrypted Media Extensions for playback of encrypted content in Video.js
511 lines (451 loc) • 17.4 kB
JavaScript
import videojs from 'video.js';
import window from 'global/window';
import { standard5July2016, getSupportedKeySystem } from './eme';
import {
default as fairplay,
LEGACY_FAIRPLAY_KEY_SYSTEM
} from './fairplay';
import {
default as msPrefixed,
PLAYREADY_KEY_SYSTEM
} from './ms-prefixed';
import {detectSupportedCDMs } from './cdm.js';
import { arrayBuffersEqual, arrayBufferFrom, merge, getMediaKeySystemConfigurations } from './utils';
import {version as VERSION} from '../package.json';
import EmeError from './consts/errors';
export 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;
};
export const removeSession = (sessions, initData) => {
for (let i = 0; i < sessions.length; i++) {
if (sessions[i].initData === initData) {
sessions.splice(i, 1);
return;
}
}
};
export 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]) {
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: EmeError.EMEFailedToRequestMediaKeySystemAccess,
config: getMediaKeySystemConfigurations(options.keySystems)
};
emeError(error, metadata);
});
}
export 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
});
};
export 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
});
};
export 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
*/
export 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}
*/
export 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(player);
if (window.MediaKeys) {
handleEncryptedEvent(player, mockEncryptedEvent, mergedEmeOptions, player.eme.sessions, player.tech_, emeError)
.then(() => callback())
.catch((error) => {
callback(error);
if (!suppressErrorIfPossible) {
emeError(error);
}
});
} else if (window.MSMediaKeys) {
const msKeyHandler = (event) => {
player.tech_.off('mskeyadded', msKeyHandler);
player.tech_.off('mskeyerror', msKeyHandler);
if (event.type === 'mskeyerror') {
callback(event.target.error);
if (!suppressErrorIfPossible) {
emeError(event.message);
}
} else {
callback();
}
};
player.tech_.one('mskeyadded', msKeyHandler);
player.tech_.one('mskeyerror', msKeyHandler);
try {
handleMsNeedKeyEvent(mockEncryptedEvent, mergedEmeOptions, player.eme.sessions, player.tech_, emeError);
} catch (error) {
player.tech_.off('mskeyadded', msKeyHandler);
player.tech_.off('mskeyerror', msKeyHandler);
callback(error);
if (!suppressErrorIfPossible) {
emeError(error);
}
}
}
},
initLegacyFairplay() {
const handleFn = (event) => {
videojs.log.debug('eme', 'Received a \'webkitneedkey\' event');
// TODO it's possible that the video state must be cleared if reusing the same video
// element between sources
setupSessions(player);
handleWebKitNeedKeyEvent(event, getOptions(player), player.tech_, emeError)
.catch((error) => {
emeError(error);
});
};
const webkitNeedKeyEventHandler = (event) => {
const firstWebkitneedkeyTimeout = getOptions(player).firstWebkitneedkeyTimeout || 1000;
const src = player.src();
// on source change or first startup reset webkitneedkey options.
player.eme.webkitneedkey_ = player.eme.webkitneedkey_ || {};
// if the source changed we need to handle the first event again.
// track source changes internally.
if (player.eme.webkitneedkey_.src !== src) {
player.eme.webkitneedkey_ = {
handledFirstEvent: false,
src
};
}
// It's possible that at the start of playback a rendition switch
// on a small player in safari's HLS implementation will cause
// two webkitneedkey events to occur. We want to make sure to cancel
// our first existing request if we get another within 1 second. This
// prevents a non-fatal player error from showing up due to a
// request failure.
if (!player.eme.webkitneedkey_.handledFirstEvent) {
// clear the old timeout so that a new one can be created
// with the new rendition's event data
player.clearTimeout(player.eme.webkitneedkey_.timeout);
player.eme.webkitneedkey_.timeout = player.setTimeout(() => {
player.eme.webkitneedkey_.handledFirstEvent = true;
player.eme.webkitneedkey_.timeout = null;
handleFn(event);
}, firstWebkitneedkeyTimeout);
// after we have a verified first request, we will request on
// every other event like normal.
} else {
handleFn(event);
}
};
let videoElement = player.tech_.el_;
// Support Safari EME with FairPlay
// (also used in early Chrome or Chrome with EME disabled flag)
videoElement.addEventListener('webkitneedkey', webkitNeedKeyEventHandler);
const cleanupWebkitNeedKeyHandler = () => {
// no need in auto-cleanup if manual clean is called
player.off('dispose', cleanupWebkitNeedKeyHandler);
// check for null, if manual cleanup is called multiple times for any reason
if (videoElement !== null) {
videoElement.removeEventListener('webkitneedkey', webkitNeedKeyEventHandler);
}
videoElement = null;
};
// auto-cleanup:
player.on('dispose', cleanupWebkitNeedKeyHandler);
// returning for manual cleanup
return cleanupWebkitNeedKeyHandler;
},
detectSupportedCDMs,
options
};
};
// Register the plugin with video.js.
videojs.registerPlugin('eme', eme);
// contrib-eme specific error const
eme.Error = EmeError;
// Include the version number.
eme.VERSION = VERSION;
export default eme;