Skip to content

Commit

Permalink
fix: Wait for EME initialization before appending content (#1002)
Browse files Browse the repository at this point in the history
This is particularly important for Chrome, where, if unencrypted
content is appended before encrypted content and the key session
has not been created, a MEDIA_ERR_DECODE will be thrown once the
encrypted content is reached during playback.
  • Loading branch information
gesinger authored Dec 1, 2020
1 parent 00d9b1d commit 93132b7
Show file tree
Hide file tree
Showing 10 changed files with 586 additions and 262 deletions.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"shelljs": "^0.8.2",
"sinon": "^8.1.1",
"url-toolkit": "^2.1.3",
"videojs-contrib-eme": "^3.2.0",
"videojs-contrib-eme": "^3.8.0",
"videojs-contrib-quality-levels": "^2.0.4",
"videojs-generate-karma-config": "~7.0.0",
"videojs-generate-rollup-config": "~5.0.1",
Expand Down
12 changes: 9 additions & 3 deletions src/master-playlist-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,7 @@ export class MasterPlaylistController extends videojs.EventTarget {
});

const updateCodecs = () => {
if (!this.sourceUpdater_.ready()) {
if (!this.sourceUpdater_.hasCreatedSourceBuffers()) {
return this.tryToCreateSourceBuffers_();
}

Expand Down Expand Up @@ -1549,7 +1549,10 @@ export class MasterPlaylistController extends videojs.EventTarget {
return;
}
// check if codec switching is happening
if (this.sourceUpdater_.ready() && !this.sourceUpdater_.canChangeType()) {
if (
this.sourceUpdater_.hasCreatedSourceBuffers() &&
!this.sourceUpdater_.canChangeType()
) {
const switchMessages = [];

['video', 'audio'].forEach((type) => {
Expand Down Expand Up @@ -1585,7 +1588,10 @@ export class MasterPlaylistController extends videojs.EventTarget {
tryToCreateSourceBuffers_() {
// media source is not ready yet or sourceBuffers are already
// created.
if (this.mediaSource.readyState !== 'open' || this.sourceUpdater_.ready()) {
if (
this.mediaSource.readyState !== 'open' ||
this.sourceUpdater_.hasCreatedSourceBuffers()
) {
return;
}

Expand Down
1 change: 0 additions & 1 deletion src/segment-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -1713,7 +1713,6 @@ export default class SegmentLoader extends videojs.EventTarget {

hasEnoughInfoToAppend_() {
if (!this.sourceUpdater_.ready()) {
// waiting on one of the segment loaders to get enough data to create source buffers
return false;
}

Expand Down
39 changes: 32 additions & 7 deletions src/source-updater.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ const shiftQueue = (type, sourceUpdater) => {
// Media source queue entries don't need to consider whether the source updater is
// started (i.e., source buffers are created) as they don't need the source buffers, but
// source buffer queue entries do.
if (!sourceUpdater.started_ || sourceUpdater.mediaSource.readyState === 'closed' || updating(type, sourceUpdater)) {
if (
!sourceUpdater.ready() ||
sourceUpdater.mediaSource.readyState === 'closed' ||
updating(type, sourceUpdater)
) {
return;
}

Expand Down Expand Up @@ -331,24 +335,45 @@ export default class SourceUpdater extends videojs.EventTarget {
// used for debugging
this.audioError_ = e;
};
this.started_ = false;
this.createdSourceBuffers_ = false;
this.initializedEme_ = false;
}

initializedEme() {
this.initializedEme_ = true;
if (this.ready()) {
this.trigger('ready');
}
}

hasCreatedSourceBuffers() {
// if false, likely waiting on one of the segment loaders to get enough data to create
// source buffers
return this.createdSourceBuffers_;
}

hasInitializedAnyEme() {
return this.initializedEme_;
}

ready() {
return this.started_;
return this.hasCreatedSourceBuffers() && this.hasInitializedAnyEme();
}

createSourceBuffers(codecs) {
if (this.ready()) {
if (this.hasCreatedSourceBuffers()) {
// already created them before
return;
}

// the intial addOrChangeSourceBuffers will always be
// two add buffers.
this.addOrChangeSourceBuffers(codecs);
this.started_ = true;
this.trigger('ready');
this.createdSourceBuffers_ = true;
this.trigger('createdsourcebuffers');
if (this.ready()) {
this.trigger('ready');
}
}

/**
Expand Down Expand Up @@ -484,7 +509,7 @@ export default class SourceUpdater extends videojs.EventTarget {
Object.keys(codecs).forEach((type) => {
const codec = codecs[type];

if (!this.ready()) {
if (!this.hasCreatedSourceBuffers()) {
return this.addSourceBuffer(type, codec);
}

Expand Down
186 changes: 146 additions & 40 deletions src/videojs-http-streaming.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
comparePlaylistResolution
} from './playlist-selectors.js';
import {isAudioCodec, isVideoCodec, browserSupportsCodec} from '@videojs/vhs-utils/dist/codecs.js';
import logger from './util/logger';

// IMPORTANT:
// keep these at the bottom they are replaced at build time
Expand Down Expand Up @@ -165,6 +166,12 @@ const emeKeySystems = (keySystemOptions, videoPlaylist, audioPlaylist) => {
for (const keySystem in keySystemOptions) {
keySystemContentTypes[keySystem] = {audioContentType, videoContentType};

// Default to using the video playlist's PSSH even though they may be different, as
// videojs-contrib-eme will only accept one in the options.
//
// This shouldn't be an issue for most cases as early intialization will handle all
// unique PSSH values, and if they aren't, then encrypted events should have the
// specific information needed for the unique license.
if (videoPlaylist.contentProtection &&
videoPlaylist.contentProtection[keySystem] &&
videoPlaylist.contentProtection[keySystem].pssh) {
Expand Down Expand Up @@ -230,47 +237,37 @@ const getAllPsshKeySystemsOptions = (playlists, keySystems) => {
};

/**
* If the [eme](https://github.com/videojs/videojs-contrib-eme) plugin is available, and
* there are keySystems on the source, sets up source options to prepare the source for
* eme and tries to initialize it early via eme's initializeMediaKeys API (if available).
* Returns a promise that waits for the
* [eme plugin](https://github.com/videojs/videojs-contrib-eme) to create a key session.
*
* Works around https://bugs.chromium.org/p/chromium/issues/detail?id=895449 in non-IE11
* browsers.
*
* As per the above ticket, this is particularly important for Chrome, where, if
* unencrypted content is appended before encrypted content and the key session has not
* been created, a MEDIA_ERR_DECODE will be thrown once the encrypted content is reached
* during playback.
*
* @param {Object} player
* The player instance
* @param {Object[]} sourceKeySystems
* The key systems options from the player source
* @param {Object} media
* The active media playlist
* @param {Object} [audioMedia]
* The active audio media playlist (optional)
* @param {Object[]} mainPlaylists
* The playlists found on the master playlist object
*
* @return {Object}
* Promise that resolves when the key session has been created
*/
const setupEmeOptions = ({
export const waitForKeySessionCreation = ({
player,
sourceKeySystems,
media,
audioMedia,
mainPlaylists
}) => {
const sourceOptions = emeKeySystems(sourceKeySystems, media, audioMedia);

if (!sourceOptions) {
return;
}

player.currentSource().keySystems = sourceOptions;

// eme handles the rest of the setup, so if it is missing
// do nothing.
if (sourceOptions && !player.eme) {
videojs.log.warn('DRM encrypted source cannot be decrypted without a DRM plugin');
return;
}

// works around https://bugs.chromium.org/p/chromium/issues/detail?id=895449
// in non-IE11 browsers. In IE11 this is too early to initialize media keys
if (videojs.browser.IE_VERSION === 11 || !player.eme.initializeMediaKeys) {
return;
if (!player.eme.initializeMediaKeys) {
return Promise.resolve();
}

// TODO should all audio PSSH values be initialized for DRM?
Expand All @@ -288,16 +285,87 @@ const setupEmeOptions = ({
Object.keys(sourceKeySystems)
);

const initializationFinishedPromises = [];
const keySessionCreatedPromises = [];

// Since PSSH values are interpreted as initData, EME will dedupe any duplicates. The
// only place where it should not be deduped is for ms-prefixed APIs, but the early
// return for IE11 above, and the existence of modern EME APIs in addition to
// ms-prefixed APIs on Edge should prevent this from being a concern.
// initializeMediaKeys also won't use the webkit-prefixed APIs.
keySystemsOptionsArr.forEach((keySystemsOptions) => {
player.eme.initializeMediaKeys({
keySystems: keySystemsOptions
});
keySessionCreatedPromises.push(new Promise((resolve, reject) => {
player.tech_.one('keysessioncreated', resolve);
}));

initializationFinishedPromises.push(new Promise((resolve, reject) => {
player.eme.initializeMediaKeys({
keySystems: keySystemsOptions
}, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
}));
});

// The reasons Promise.race is chosen over Promise.any:
//
// * Promise.any is only available in Safari 14+.
// * None of these promises are expected to reject. If they do reject, it might be
// better here for the race to surface the rejection, rather than mask it by using
// Promise.any.
return Promise.race([
// If a session was previously created, these will all finish resolving without
// creating a new session, otherwise it will take until the end of all license
// requests, which is why the key session check is used (to make setup much faster).
Promise.all(initializationFinishedPromises),
// Once a single session is created, the browser knows DRM will be used.
Promise.race(keySessionCreatedPromises)
]);
};

/**
* If the [eme](https://github.com/videojs/videojs-contrib-eme) plugin is available, and
* there are keySystems on the source, sets up source options to prepare the source for
* eme.
*
* @param {Object} player
* The player instance
* @param {Object[]} sourceKeySystems
* The key systems options from the player source
* @param {Object} media
* The active media playlist
* @param {Object} [audioMedia]
* The active audio media playlist (optional)
*
* @return {boolean}
* Whether or not options were configured and EME is available
*/
const setupEmeOptions = ({
player,
sourceKeySystems,
media,
audioMedia
}) => {
const sourceOptions = emeKeySystems(sourceKeySystems, media, audioMedia);

if (!sourceOptions) {
return false;
}

player.currentSource().keySystems = sourceOptions;

// eme handles the rest of the setup, so if it is missing
// do nothing.
if (sourceOptions && !player.eme) {
videojs.log.warn('DRM encrypted source cannot be decrypted without a DRM plugin');
return false;
}

return true;
};

const getVhsLocalStorage = () => {
Expand Down Expand Up @@ -446,6 +514,8 @@ class VhsHandler extends Component {
videojs.log.warn('Using hls options is deprecated. Use vhs instead.');
}

this.logger_ = logger('VhsHandler');

// tech.player() is deprecated but setup a reference to HLS for
// backwards-compatibility
if (tech.options_ && tech.options_.playerId) {
Expand Down Expand Up @@ -840,17 +910,8 @@ class VhsHandler extends Component {
renditionSelectionMixin(this);
});

this.masterPlaylistController_.sourceUpdater_.on('ready', () => {
const audioPlaylistLoader =
this.masterPlaylistController_.mediaTypes_.AUDIO.activePlaylistLoader;

setupEmeOptions({
player: this.player_,
sourceKeySystems: this.source_.keySystems,
media: this.playlists.media(),
audioMedia: audioPlaylistLoader && audioPlaylistLoader.media(),
mainPlaylists: this.playlists.master.playlists
});
this.masterPlaylistController_.sourceUpdater_.on('createdsourcebuffers', () => {
this.setupEme_();
});

// the bandwidth of the primary segment loader is our best
Expand Down Expand Up @@ -878,6 +939,51 @@ class VhsHandler extends Component {
this.tech_.src(this.mediaSourceUrl_);
}

/**
* If necessary and EME is available, sets up EME options and waits for key session
* creation.
*
* This function also updates the source updater so taht it can be used, as for some
* browsers, EME must be configured before content is appended (if appending unencrypted
* content before encrypted content).
*/
setupEme_() {
const audioPlaylistLoader =
this.masterPlaylistController_.mediaTypes_.AUDIO.activePlaylistLoader;

const didSetupEmeOptions = setupEmeOptions({
player: this.player_,
sourceKeySystems: this.source_.keySystems,
media: this.playlists.media(),
audioMedia: audioPlaylistLoader && audioPlaylistLoader.media()
});

// In IE11 this is too early to initialize media keys, and IE11 does not support
// promises.
if (videojs.browser.IE_VERSION === 11 || !didSetupEmeOptions) {
// If EME options were not set up, we've done all we could to initialize EME.
this.masterPlaylistController_.sourceUpdater_.initializedEme();
return;
}

this.logger_('waiting for EME key session creation');
waitForKeySessionCreation({
player: this.player_,
sourceKeySystems: this.source_.keySystems,
audioMedia: audioPlaylistLoader && audioPlaylistLoader.media(),
mainPlaylists: this.playlists.master.playlists
}).then(() => {
this.logger_('created EME key session');
this.masterPlaylistController_.sourceUpdater_.initializedEme();
}).catch((err) => {
this.logger_('error while creating EME key session', err);
this.player_.error({
message: 'Failed to initialize media keys for EME',
code: 3
});
});
}

/**
* Initializes the quality levels and sets listeners to update them.
*
Expand Down
Loading

0 comments on commit 93132b7

Please sign in to comment.