UNPKG

videojs-contrib-eme

Version:

Supports Encrypted Media Extensions for playback of encrypted content in Video.js

511 lines (451 loc) 17.4 kB
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;