From 689c2a47aa31f99b3d2a6da9631cb7c8a0111a5b Mon Sep 17 00:00:00 2001 From: Timothy Drews Date: Mon, 20 Jul 2015 15:45:36 -0700 Subject: [PATCH] Use 'keystatuseschange' events and support @group. Use 'keystatuseschange' events from EME together with cenc:default_KID from the MPD so StreamVideoSource can determine if the key system can/will decrypt a stream before it attempts to play it. This enables the Player to safely (and automatically) switch between streams that are encrypted with different keys. Support AdaptationSet @group, which is the preferred approach to safely use multiple encryption keys over multiple Representations. * Squash AdaptationSets from the same group into one StreamSetInfo. This enables support for @group without introducing special case code into StreamVideoSource and EmeManager. * Fire 'trackchanged' events when tracks becomes available/unavailable either from application restrictions or key status changes. * Pull key IDs out of DrmInfo and put them into StreamInfo so key status changes can drive per stream availability instead of per StreamSet (AdaptationSet) availability. Closes #67 Issue #160 Change-Id: Ife0814deb65715923a572b45880137a99b378035 --- app.js | 1 + lib/dash/mpd_processor.js | 255 +++++++++++++++++++++++------- lib/media/eme_manager.js | 78 +++------ lib/media/stream_info.js | 39 ++++- lib/player/drm_info.js | 18 --- lib/player/http_video_source.js | 20 +++ lib/player/i_video_source.js | 12 ++ lib/player/stream_video_source.js | 177 +++++++++++++++++---- lib/util/eme_utils.js | 64 ++++++++ lib/util/string_utils.js | 13 ++ 10 files changed, 510 insertions(+), 167 deletions(-) create mode 100644 lib/util/eme_utils.js diff --git a/app.js b/app.js index 7a66d5c0a..2ec6cad66 100644 --- a/app.js +++ b/app.js @@ -928,6 +928,7 @@ app.initPlayer_ = function() { playerControls.onBuffering.bind(null, false)); app.player_.addEventListener('seekrangechanged', playerControls.onSeekRangeChanged); + app.player_.addEventListener('trackschanged', app.displayMetadata_); app.estimator_ = new shaka.util.EWMABandwidthEstimator(); playerControls.setPlayer(app.player_); diff --git a/lib/dash/mpd_processor.js b/lib/dash/mpd_processor.js index 0124cae7e..018949ca0 100644 --- a/lib/dash/mpd_processor.js +++ b/lib/dash/mpd_processor.js @@ -349,51 +349,35 @@ shaka.dash.MpdProcessor.prototype.createManifestInfo_ = function( var periodInfo = new shaka.media.PeriodInfo(); periodInfo.id = period.id; - - shaka.asserts.assert(period.start != null); - periodInfo.start = period.start || 0; + periodInfo.start = period.start; periodInfo.duration = period.duration; - for (var j = 0; j < period.adaptationSets.length; ++j) { - var adaptationSet = period.adaptationSets[j]; - shaka.asserts.assert(adaptationSet.group != null); + // First group AdaptationSets by type. + var setsByType = new shaka.util.MultiMap(); + period.adaptationSets.forEach( + function(set) { setsByType.push(set.contentType || '', set); }); - var streamSetInfo = new shaka.media.StreamSetInfo(); - streamSetInfo.id = adaptationSet.id; - streamSetInfo.group = /** @type {number} */(adaptationSet.group); - streamSetInfo.lang = adaptationSet.lang || ''; - streamSetInfo.contentType = adaptationSet.contentType || ''; - streamSetInfo.main = adaptationSet.main; + var setTypes = setsByType.keys(); + for (var typeIdx = 0; typeIdx < setTypes.length; ++typeIdx) { + var type = setTypes[typeIdx]; - for (var k = 0; k < adaptationSet.representations.length; ++k) { - var representation = adaptationSet.representations[k]; + // Now group AdaptationSets of the same type by group to squash multiple + // AdaptationSets into a single StreamSetInfo. + var setsByGroup = new shaka.util.MultiMap(); + setsByType.get(type).forEach( + function(set) { setsByGroup.push(set.group, set); }); - // Get common DRM infos. - var commonDrmInfos = streamSetInfo.drmInfos.slice(0); - this.updateCommonDrmInfos_(representation, commonDrmInfos); - if (commonDrmInfos.length == 0 && - streamSetInfo.drmInfos.length > 0) { - shaka.log.warning( - 'Representation does not contain any ContentProtection elements', - 'that are compatible with other Representations within its', - 'AdaptationSet.', - representation); - continue; - } + var setGroups = setsByGroup.keys(); + for (var groupIdx = 0; groupIdx < setGroups.length; ++groupIdx) { + var group = setGroups[groupIdx]; - var streamInfo = this.createStreamInfo_( - mpd, period, representation, manifestCreationTime, networkCallback); - if (!streamInfo) { - // An error has already been logged. - continue; - } - - streamSetInfo.streamInfos.push(streamInfo); - streamSetInfo.drmInfos = commonDrmInfos; - } // for k - - periodInfo.streamSetInfos.push(streamSetInfo); - } // for j + var sets = /** @type {!Array.} */( + setsByGroup.get(group)); + var streamSetInfo = this.createStreamSetInfo_( + mpd, period, sets, manifestCreationTime, networkCallback); + periodInfo.streamSetInfos.push(streamSetInfo); + } + } // for typeIdx manifestInfo.periodInfos.push(periodInfo); } // for i @@ -403,26 +387,179 @@ shaka.dash.MpdProcessor.prototype.createManifestInfo_ = function( /** - * Updates |commonDrmInfos|. + * Creates a StreamSetInfo from AdaptationSets from the same group. * - * If |commonDrmInfos| is empty then after this function is called - * |commonDrmInfos| will equal |representation|'s application provided - * DrmInfos. + * @param {!shaka.dash.mpd.Mpd} mpd + * @param {!shaka.dash.mpd.Period} period + * @param {Array.} adaptationSets + * AdaptationSets from the same group. + * @param {number} manifestCreationTime The time, in seconds, when the manifest + * was created. + * @param {shaka.util.FailoverUri.NetworkCallback} networkCallback + * @return {!shaka.media.StreamSetInfo} + * @private + */ +shaka.dash.MpdProcessor.prototype.createStreamSetInfo_ = function( + mpd, period, adaptationSets, manifestCreationTime, networkCallback) { + shaka.asserts.assert(adaptationSets.length > 0); + + var streamSetInfo = new shaka.media.StreamSetInfo(); + + // Construct a StreamSetInfo ID from the AdaptationSet IDs. Consider the + // entire StreamSetInfo unidentifiable if an AdaptationSet (in the group) + // does not have an ID. StreamSetInfo IDs must remain identical between + // manifest updates, see shaka.media.ManifestUpdater. + var identifiable = adaptationSets + .filter(function(set) { return set.id != null; }); + if (identifiable.length == adaptationSets.length) { + streamSetInfo.id = identifiable + .map(function(set) { return set.id; }) + .sort() + .reduce(function(all, part) { return all + ',' + part; }); + } + + streamSetInfo.lang = adaptationSets[0].lang || ''; + streamSetInfo.contentType = adaptationSets[0].contentType || ''; + + // Maps each StreamInfo to its AdaptationSet, which we use below to enable + // certain StreamInfos. + var streamInfoUidToAdaptationSet = {}; + + for (var i = 0; i < adaptationSets.length; ++i) { + var adaptationSet = adaptationSets[i]; + + if ((adaptationSet.lang || '') != streamSetInfo.lang) { + shaka.log.warning( + 'AdaptationSet groups should have the same language.', + adaptationSets); + } + + if ((adaptationSet.contentType || '') != streamSetInfo.contentType) { + shaka.log.error( + 'AdaptationSet groups must have the same content type:', + 'ignoring AdaptationSet.', + adaptationSet); + continue; + } + + for (var j = 0; j < adaptationSet.representations.length; ++j) { + var representation = adaptationSet.representations[j]; + var drmInfos = this.getDrmInfos_(representation); + + // Update the common DrmInfos. + var commonDrmInfos = streamSetInfo.drmInfos.slice(0); + this.updateCommonDrmInfos_(drmInfos, commonDrmInfos); + if (commonDrmInfos.length == 0 && streamSetInfo.drmInfos.length > 0) { + shaka.log.warning( + 'Representation does not contain any ContentProtection elements', + 'that are compatible with other Representations within its', + 'AdaptationSet or AdaptationSet group.', + representation); + continue; + } + + var streamInfo = this.createStreamInfo_( + mpd, period, representation, manifestCreationTime, networkCallback); + if (!streamInfo) { + // An error has already been logged. + continue; + } + + streamSetInfo.streamInfos.push(streamInfo); + streamSetInfo.drmInfos = commonDrmInfos; + + streamInfoUidToAdaptationSet[streamInfo.uniqueId] = adaptationSet; + } // for j + } // for i + + // If the streams are unencrypted then we don't require a key system. + var unencrypted = streamSetInfo.drmInfos.some( + function(drmInfo) { return drmInfo.keySystem == ''; }); + if (unencrypted) { + streamSetInfo.streamInfos.forEach( + function(streamInfo) { streamInfo.allowedByKeySystem = true; }); + return streamSetInfo; + } + + // If the streams are encrypted then assume the key system can probably + // decrypt the lowest quality streams. This enables the VideoSource to + // select at least one stream for stream startup without having to query the + // key system first. + // + // The lowest quality streams are the streams that are contained within + // the AdaptationSet which contains THE lowest quality stream. + // + // For example, given an MPD that looks like + // + // + // + // + // + // + // + // + // + // the lowest quality streams are R1 and R2. + var lowestQualitySet = this.findLowestQualityAdaptationSet_(adaptationSets); + + for (var i = 0; i < streamSetInfo.streamInfos.length; ++i) { + var streamInfo = streamSetInfo.streamInfos[i]; + var adaptationSet = streamInfoUidToAdaptationSet[streamInfo.uniqueId]; + if (adaptationSet == lowestQualitySet) { + shaka.log.v1( + 'Assuming the key system can decrypt stream', streamInfo.id + '.'); + streamInfo.allowedByKeySystem = true; + } + } + + return streamSetInfo; +}; + + +/** + * Returns the AdaptationSet that contains the lowest quality Representation. * - * Otherwise, if |commonDrmInfos| is non-empty then after this function is - * called |commonDrmInfos| will equal the intersection between - * |representation|'s application provided DrmInfos and |commonDrmInfos| at the - * time this function was called. + * @param {!Array.} adaptationSets + * @return {shaka.dash.mpd.AdaptationSet} + * @private + */ +shaka.dash.MpdProcessor.prototype.findLowestQualityAdaptationSet_ = function( + adaptationSets) { + var lowestQuality = null; + var lowestQualitySet = null; + + for (var i = 0; i < adaptationSets.length; ++i) { + var adaptationSet = adaptationSets[i]; + + for (var j = 0; j < adaptationSet.representations.length; ++j) { + var representation = adaptationSet.representations[j]; + + var quality = (representation.width || 1) * + (representation.height || 1) * + (representation.bandwidth || 1); + if ((lowestQuality == null) || (quality < lowestQuality)) { + lowestQuality = quality; + lowestQualitySet = adaptationSet; + } + } + } + + return lowestQualitySet; +}; + + +/** + * Sets |commonDrmInfos| to |drmInfos| if |commonDrmInfos| is empty; otherwise, + * sets |commonDrmInfos| to the intersection between |commonDrmInfos| and + * |drmInfos|. * - * @param {!shaka.dash.mpd.Representation} representation + * @param {!Array.} drmInfos * @param {!Array.} commonDrmInfos * * @private */ shaka.dash.MpdProcessor.prototype.updateCommonDrmInfos_ = function( - representation, commonDrmInfos) { - var drmInfos = this.getDrmInfos_(representation); - + drmInfos, commonDrmInfos) { if (commonDrmInfos.length == 0) { Array.prototype.push.apply(commonDrmInfos, drmInfos); return; @@ -453,8 +590,7 @@ shaka.dash.MpdProcessor.prototype.updateCommonDrmInfos_ = function( * is used for unencrypted content. * @private */ -shaka.dash.MpdProcessor.prototype.getDrmInfos_ = - function(representation) { +shaka.dash.MpdProcessor.prototype.getDrmInfos_ = function(representation) { var drmInfos = []; if (representation.contentProtections.length == 0) { // Return a single item which indicates that the content is unencrypted. @@ -508,10 +644,6 @@ shaka.dash.MpdProcessor.prototype.createDrmInfos_ = function( drmInfo.addInitDatas([initData]); } - if (contentProtection.defaultKeyId) { - drmInfo.addKeyIds([contentProtection.defaultKeyId]); - } - drmInfos.push(drmInfo); } } else { @@ -613,6 +745,15 @@ shaka.dash.MpdProcessor.prototype.createStreamInfo_ = function( streamInfo.mimeType = representation.mimeType || ''; streamInfo.codecs = representation.codecs || ''; + for (var i = 0; i < representation.contentProtections.length; ++i) { + // Since we don't know which key system we will use yet, map each + // cenc:default_KID that the manifest specifies. + var contentProtection = representation.contentProtections[i]; + if (contentProtection.defaultKeyId) { + streamInfo.keyIds.push(contentProtection.defaultKeyId); + } + } + return streamInfo; }; diff --git a/lib/media/eme_manager.js b/lib/media/eme_manager.js index 8e17f2037..706c07ff5 100644 --- a/lib/media/eme_manager.js +++ b/lib/media/eme_manager.js @@ -479,17 +479,17 @@ shaka.media.EmeManager.prototype.generateFakeEncryptedEvents_ = function() { * @private */ shaka.media.EmeManager.prototype.onEncrypted_ = function(event) { - // Suppress duplicate init data. shaka.asserts.assert(event.initData); var initData = new Uint8Array(event.initData); - var initDataKey = shaka.util.Uint8ArrayUtils.key(initData); + shaka.log.info('onEncrypted_', initData, event); - shaka.asserts.assert(this.drmInfo_); + // Suppress duplicate init data. + var initDataKey = shaka.util.Uint8ArrayUtils.key(initData); if (this.requestGenerated_[initDataKey]) { + shaka.log.debug('License request already generated!'); return; } - shaka.log.info('onEncrypted_', initData, event); try { var session = this.createSession_(); } catch (exception) { @@ -498,7 +498,7 @@ shaka.media.EmeManager.prototype.onEncrypted_ = function(event) { this.allSessionsPresumedReady_.reject(exception); return; } - shaka.asserts.assert(event.initData); + var p = session.generateRequest(event.initDataType, /** @type {!BufferSource} */(event.initData)); this.requestGenerated_[initDataKey] = true; @@ -567,7 +567,7 @@ shaka.media.EmeManager.prototype.createSession_ = function() { this.onSessionMessage_.bind(this))); this.eventManager_.listen(session, 'keystatuseschange', /** @type {shaka.util.EventManager.ListenerType} */( - this.onKeyStatusChange_.bind(this))); + this.onKeyStatusesChange_.bind(this))); return session; }; @@ -594,20 +594,25 @@ shaka.media.EmeManager.prototype.onSessionMessage_ = function(event) { * @param {!Event} event * @private */ -shaka.media.EmeManager.prototype.onKeyStatusChange_ = function(event) { - shaka.log.info('onKeyStatusChange_', event); +shaka.media.EmeManager.prototype.onKeyStatusesChange_ = function(event) { + shaka.log.debug('onKeyStatusesChange_', event); + var session = /** @type {!MediaKeySession} */(event.target); - var map = session.keyStatuses; - var i = map.values(); - for (var v = i.next(); !v.done; v = i.next()) { - var message = shaka.media.EmeManager.getErrorMessage_(v.value); - if (message) { - var error = new Error(message); - error.type = v.value; - var errorEvent = shaka.util.FakeEvent.createErrorEvent(error); - this.dispatchEvent(errorEvent); - } + var keyStatusMap = session.keyStatuses; + + /** @type {!Object.} */ + var keyStatusByKeyId = {}; + + var itr = keyStatusMap.keys(); + for (var key = itr.next(); !key.done; key = itr.next()) { + var keyAsHexString = + shaka.util.Uint8ArrayUtils.toHex(new Uint8Array(key.value)); + var status = keyStatusMap.get(key.value); + shaka.asserts.assert(status != null); + keyStatusByKeyId[keyAsHexString] = /** @type {string} */(status); } + + this.videoSource_.onKeyStatusesChange(keyStatusByKeyId); }; @@ -743,40 +748,3 @@ shaka.media.EmeManager.prototype.setLicenseRequestTimeout = function(timeout) { this.licenseRequestTimeout_ = timeout; }; - -/** - * Gets the error message for the given status code. - * - * @param {string} status - * @return {?string} - * @private - */ -shaka.media.EmeManager.getErrorMessage_ = function(status) { - var message = shaka.media.EmeManager.KEY_STATUS_ERROR_MAP_[status]; - // usable, output-downscaled, and status-pending do not result in errors. - // the assertion helps catch future status message changes. - shaka.asserts.assert(message || - status === 'usable' || - status === 'output-downscaled' || - status === 'status-pending'); - - return message; -}; - - -/** - * A map of key statuses to errors. Not every key status appears in the map, - * in which case that key status is not treated as an error. - * - * @private {!Object.} - * @const - */ -shaka.media.EmeManager.KEY_STATUS_ERROR_MAP_ = { - 'output-restricted': 'The required output protection is not available.', - // This has been removed from the EME spec and deprecated, but some browsers - // may still use it. - 'output-not-allowed': 'The required output protection is not available.', - 'expired': 'A required key has expired and the content cannot be decrypted.', - 'internal-error': 'An unknown error has occurred in the CDM.' -}; - diff --git a/lib/media/stream_info.js b/lib/media/stream_info.js index a4e493056..e92295f5e 100644 --- a/lib/media/stream_info.js +++ b/lib/media/stream_info.js @@ -83,8 +83,26 @@ shaka.media.StreamInfo = function() { /** @type {string} */ this.codecs = ''; - /** @type {boolean} */ - this.enabled = true; + /** + * Key IDs, provided in the manifest, as hex strings. + * @type {!Array.} + */ + this.keyIds = []; + + /** + * True if the stream is + * 1. unencrypted; + * 2. encrypted but a usable license is expected to be acquired; + * 3. encrypted and a usable license has already been acquired. + * @type {boolean} + */ + this.allowedByKeySystem = false; + + /** + * True if the application has not restricted the stream. + * @type {boolean} + */ + this.allowedByApplication = true; }; @@ -111,6 +129,15 @@ shaka.media.StreamInfo.prototype.destroy = function() { }; +/** + * @return {boolean} True if the stream is allowed by both the key system and + * the application; otherwise, return false. + */ +shaka.media.StreamInfo.prototype.usable = function() { + return this.allowedByKeySystem && this.allowedByApplication; +}; + + /** * Gets the StreamInfos's content type, which is the first part of the MIME * type. @@ -151,9 +178,6 @@ shaka.media.StreamSetInfo = function() { /** @type {?string} */ this.id = null; - /** @const {number} */ - this.group; - /** @type {string} */ this.lang = ''; @@ -166,7 +190,10 @@ shaka.media.StreamSetInfo = function() { /** @type {!Array.} */ this.streamInfos = []; - /** @type {!Array.} */ + /** + * The DrmInfos that are compatible with each StreamInfo. + * @type {!Array.} + */ this.drmInfos = []; }; diff --git a/lib/player/drm_info.js b/lib/player/drm_info.js index daabfb783..0dcf2dfa9 100644 --- a/lib/player/drm_info.js +++ b/lib/player/drm_info.js @@ -74,12 +74,6 @@ shaka.player.DrmInfo = function() { /** @type {!Array.} */ this.initDatas = []; - - /** - * Key IDs as hex strings. - * @type {!Array.} - */ - this.keyIds = []; }; @@ -440,7 +434,6 @@ shaka.player.DrmInfo.prototype.combine = function(other) { } this.addInitDatas(other.initDatas); - this.addKeyIds(other.keyIds); }; @@ -489,14 +482,3 @@ shaka.player.DrmInfo.prototype.addInitDatas = function( unfilteredInitDatas, initDataKey); }; - -/** - * Adds the given key IDs, as hex strings (removing duplicates). - * - * @param {!Array.} otherKeyIds - */ -shaka.player.DrmInfo.prototype.addKeyIds = function(otherKeyIds) { - var unfilteredKeyIds = this.keyIds.concat(otherKeyIds); - this.keyIds = shaka.util.ArrayUtils.removeDuplicates(unfilteredKeyIds); -}; - diff --git a/lib/player/http_video_source.js b/lib/player/http_video_source.js index 51be853ef..de0e851be 100644 --- a/lib/player/http_video_source.js +++ b/lib/player/http_video_source.js @@ -22,7 +22,10 @@ goog.require('shaka.features'); goog.require('shaka.media.StreamConfig'); goog.require('shaka.player.DrmInfo'); goog.require('shaka.player.IVideoSource'); +goog.require('shaka.util.EmeUtils'); +goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.FakeEventTarget'); +goog.require('shaka.util.MapUtils'); @@ -200,3 +203,20 @@ shaka.player.HttpVideoSource.prototype.isOffline = function() { shaka.player.HttpVideoSource.prototype.isLive = function() { return false; }; + + +/** @override */ +shaka.player.HttpVideoSource.prototype.onKeyStatusesChange = function( + keyStatusByKeyId) { + for (var keyId in keyStatusByKeyId) { + var keyStatus = keyStatusByKeyId[keyId]; + var message = shaka.util.EmeUtils.getKeyStatusErrorMessage(keyStatus); + if (message) { + var error = new Error('Key' + keyId + 'is not usable. ' + message); + error.type = 'drm'; + var event = shaka.util.FakeEvent.createErrorEvent(error); + this.dispatchEvent(event); + } + } +}; + diff --git a/lib/player/i_video_source.js b/lib/player/i_video_source.js index 36bd330b5..e7ebe8f3b 100644 --- a/lib/player/i_video_source.js +++ b/lib/player/i_video_source.js @@ -226,3 +226,15 @@ shaka.player.IVideoSource.prototype.isLive = function() {}; shaka.player.IVideoSource.prototype.setPlaybackStartTime = function(startTime) {}; + +/** + * Notifies the video source that one or more content encryption keys have + * changed their status. + * @param {!Object.} keyStatusByKeyId A map from key ID, as a + * hex string, to key status. Each key status is a + * {@link https://w3c.github.io/encrypted-media/#idl-def-MediaKeyStatus + * MediaKeyStatus} value. + */ +shaka.player.IVideoSource.prototype.onKeyStatusesChange = function( + keyStatusByKeyId) {}; + diff --git a/lib/player/stream_video_source.js b/lib/player/stream_video_source.js index f747c4d4c..c7faf68f9 100644 --- a/lib/player/stream_video_source.js +++ b/lib/player/stream_video_source.js @@ -37,6 +37,7 @@ goog.require('shaka.player.Defaults'); goog.require('shaka.player.IVideoSource'); goog.require('shaka.player.Restrictions'); goog.require('shaka.player.VideoTrack'); +goog.require('shaka.util.EmeUtils'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.FakeEventTarget'); goog.require('shaka.util.IBandwidthEstimator'); @@ -56,6 +57,16 @@ goog.require('shaka.util.PublicPromise'); */ +/** + * @event shaka.player.StreamVideoSource.TracksChangedEvent + * @description Fired when one or more audio, video, or text tracks become + * available or unavailable. + * @property {string} type 'trackschanged' + * @property {boolean} bubbles true + * @export + */ + + /** * Creates a StreamVideoSource. @@ -102,9 +113,9 @@ shaka.player.StreamVideoSource = function(manifestInfo, estimator, abrManager) { * within is mutually compatible with all other StreamInfos of the same type. * Populated in selectConfigurations(). * @protected {!shaka.util.MultiMap.} + * TODO(story 1890046): Support multiple periods. */ this.streamSetsByType = new shaka.util.MultiMap(); - // TODO(story 1890046): Support multiple periods. /** @private {!shaka.media.IAbrManager} */ this.abrManager_ = abrManager; @@ -490,7 +501,7 @@ shaka.player.StreamVideoSource.prototype.removeStream_ = function(streamInfo) { var newStreamInfos = usableStreamSetInfos .map(function(streamSetInfo) { return streamSetInfo.streamInfos; }) .reduce(function(all, part) { return all.concat(part); }, []) - .filter(function(streamInfo) { return streamInfo.enabled; }); + .filter(function(streamInfo) { return streamInfo.usable(); }); if (newStreamInfos.length == 0) { shaka.log.warning( 'The stream', streamInfo.id, @@ -537,9 +548,7 @@ shaka.player.StreamVideoSource.prototype.getVideoTracks = function() { var streamSetInfo = videoSets[i]; for (var j = 0; j < streamSetInfo.streamInfos.length; ++j) { var streamInfo = streamSetInfo.streamInfos[j]; - - // If not enabled, it has been restricted and should not be used. - if (!streamInfo.enabled) continue; + if (!streamInfo.usable()) continue; var id = streamInfo.uniqueId; var bandwidth = streamInfo.bandwidth; @@ -582,6 +591,8 @@ shaka.player.StreamVideoSource.prototype.getAudioTracks = function() { for (var j = 0; j < streamSetInfo.streamInfos.length; ++j) { var streamInfo = streamSetInfo.streamInfos[j]; + if (!streamInfo.usable()) continue; + var id = streamInfo.uniqueId; var bandwidth = streamInfo.bandwidth; @@ -661,8 +672,8 @@ shaka.player.StreamVideoSource.prototype.getConfigurations = /** @override */ -shaka.player.StreamVideoSource.prototype.selectConfigurations = - function(configs) { +shaka.player.StreamVideoSource.prototype.selectConfigurations = function( + configs) { if (!this.loaded_) { shaka.log.warning('Cannot call selectConfigurations() right now.'); return; @@ -793,6 +804,8 @@ shaka.player.StreamVideoSource.prototype.applyRestrictions_ = function() { return; } + var tracksChanged = false; + // Note that the *Info objects contained within this.manifestInfo are the same // objects contained within this.streamSetsByType. for (var i = 0; i < this.manifestInfo.periodInfos.length; ++i) { @@ -803,30 +816,44 @@ shaka.player.StreamVideoSource.prototype.applyRestrictions_ = function() { for (var k = 0; k < streamSetInfo.streamInfos.length; ++k) { var streamInfo = streamSetInfo.streamInfos[k]; - streamInfo.enabled = true; + + var originalAllowed = streamInfo.allowedByApplication; + streamInfo.allowedByApplication = true; if (this.restrictions_.maxWidth && streamInfo.width > this.restrictions_.maxWidth) { - streamInfo.enabled = false; + streamInfo.allowedByApplication = false; } if (this.restrictions_.maxHeight && streamInfo.height > this.restrictions_.maxHeight) { - streamInfo.enabled = false; + streamInfo.allowedByApplication = false; } if (this.restrictions_.maxBandwidth && streamInfo.bandwidth > this.restrictions_.maxBandwidth) { - streamInfo.enabled = false; + streamInfo.allowedByApplication = false; } if (this.restrictions_.minBandwidth && streamInfo.bandwidth < this.restrictions_.minBandwidth) { - streamInfo.enabled = false; + streamInfo.allowedByApplication = false; } + + if (originalAllowed == streamInfo.allowedByApplication) continue; + + shaka.log.info( + streamInfo.allowedByApplication ? 'Permitting' : 'Restricting', + 'stream', streamInfo.id + '.', + 'The application has applied new content restrictions.'); + tracksChanged = true; } // for k } // for j } // for i + + if (tracksChanged) { + this.fireTracksChangedEvent_(); + } }; @@ -848,6 +875,76 @@ shaka.player.StreamVideoSource.prototype.isLive = function() { }; +/** @override */ +shaka.player.StreamVideoSource.prototype.onKeyStatusesChange = function( + keyStatusByKeyId) { + if (!COMPILED) { + for (var keyId in keyStatusByKeyId) { + var prettyKeyId = shaka.util.StringUtils.formatHexString(keyId); + shaka.log.debug( + 'Key status:', prettyKeyId + ': ' + keyStatusByKeyId[keyId]); + } + } + + var tracksChanged = false; + + var streamInfosByKeyId = new shaka.util.MultiMap(); + + var streamSetInfos = this.streamSetsByType.getAll(); + for (var i = 0; i < streamSetInfos.length; ++i) { + var streamSetInfo = streamSetInfos[i]; + for (var j = 0; j < streamSetInfo.streamInfos.length; ++j) { + var streamInfo = streamSetInfo.streamInfos[j]; + streamInfo.keyIds.forEach( + function(keyId) { streamInfosByKeyId.push(keyId, streamInfo); }); + } + } + + for (var keyId in keyStatusByKeyId) { + var streamInfos = streamInfosByKeyId.get(keyId); + if (!COMPILED && !streamInfos) { + shaka.log.debug( + 'The key system is using an unknown key:', + shaka.util.StringUtils.formatHexString(keyId)); + continue; + } + + var keyStatus = keyStatusByKeyId[keyId]; + var message = shaka.util.EmeUtils.getKeyStatusErrorMessage(keyStatus); + + for (var i = 0; i < streamInfos.length; ++i) { + var streamInfo = streamInfos[i]; + var originalAllowed = streamInfo.allowedByKeySystem; + streamInfo.allowedByKeySystem = message === undefined; + if (originalAllowed == streamInfo.allowedByKeySystem) continue; + shaka.log.info( + streamInfo.allowedByKeySystem ? 'Permitting' : 'Restricting', + 'stream', streamInfo.id + '.', message || ''); + tracksChanged = true; + } + } + + if (tracksChanged) { + this.fireTracksChangedEvent_(); + } +}; + + +/** + * Fires a 'trackschanged' event. + * + * @private + */ +shaka.player.StreamVideoSource.prototype.fireTracksChangedEvent_ = function() { + var event = shaka.util.FakeEvent.create({ + 'type': 'trackschanged', + 'bubbles': true + }); + + this.dispatchEvent(event); +}; + + /** * Select a track by ID. * @@ -885,6 +982,20 @@ shaka.player.StreamVideoSource.prototype.selectTrack_ = var streamInfo = streamSetInfo.streamInfos[j]; if (streamInfo.uniqueId != id) continue; + if (!streamInfo.allowedByKeySystem) { + shaka.log.warning( + 'Cannot select', type, 'track', id, + 'because the track is not allowed by the key system.'); + return false; + } + + if (!streamInfo.allowedByApplication) { + shaka.log.warning( + 'Cannot select', type, 'track', id, + 'because the track is not allowed by the application.'); + return false; + } + if (type != 'text' && !this.canSwitch_) { // Note that stream switching is disabled until all SegmentIndexes have // been created. This ensures that gaps do not get introduced into the @@ -1088,32 +1199,36 @@ shaka.player.StreamVideoSource.prototype.selectStreamInfos_ = function( for (var i = 0; i < streamSetInfos.length; ++i) { var streamSetInfo = streamSetInfos[i]; - // Start by assuming we will use the first StreamInfo. - shaka.asserts.assert(streamSetInfo.streamInfos.length > 0); - var streamInfo = streamSetInfo.streamInfos[0]; - + var streamInfo = null; if (streamSetInfo.contentType == 'video') { // Ask AbrManager which video StreamInfo to start with. var trackId = this.abrManager_.getInitialVideoTrackId(); - shaka.asserts.assert(trackId != null); - var found = false; - for (var j = 0; j < streamSetInfo.streamInfos.length; ++j) { - streamInfo = streamSetInfo.streamInfos[j]; - if (streamInfo.uniqueId == trackId) { - found = true; - break; - } + if (trackId != null) { + var streamInfos = streamSetInfo.streamInfos.filter( + function(streamInfo) { return streamInfo.uniqueId == trackId; }); + shaka.asserts.assert(streamInfos.length == 1); + shaka.asserts.assert(streamInfos[0].usable()); + streamInfo = streamInfos[0]; } - shaka.asserts.assert(found); } else if (streamSetInfo.contentType == 'audio') { - // In lieu of audio adaptation, choose the middle stream from the - // available ones. If we have high, medium, and low quality audio, this - // is medium. If we only have high and low, this is high. - var index = Math.floor(streamSetInfo.streamInfos.length / 2); - streamInfo = streamSetInfo.streamInfos[index]; + var usableStreamInfos = streamSetInfo.streamInfos.filter( + function(streamInfo) { return streamInfo.usable(); }); + if (usableStreamInfos.length > 0) { + // In lieu of audio adaptation, choose the middle stream from the + // usable ones. If we have high, medium, and low quality audio, this + // is medium. If we only have high and low, this is high. + var index = Math.floor(usableStreamInfos.length / 2); + streamInfo = streamSetInfo.streamInfos[index]; + } + } else if (streamSetInfo.streamInfos.length > 0) { + streamInfo = streamSetInfo.streamInfos[0]; } - selectedStreamInfosByType[streamSetInfo.contentType] = streamInfo; + shaka.asserts.assert( + streamInfo, + 'Each StreamSetInfo should contain at least one usable StreamInfo.'); + selectedStreamInfosByType[streamSetInfo.contentType] = + /** @type {!shaka.media.StreamInfo} */(streamInfo); } return selectedStreamInfosByType; diff --git a/lib/util/eme_utils.js b/lib/util/eme_utils.js new file mode 100644 index 000000000..d7abbd32e --- /dev/null +++ b/lib/util/eme_utils.js @@ -0,0 +1,64 @@ +/** + * Copyright 2015 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @fileoverview EME utility functions. + */ + + +goog.provide('shaka.util.EmeUtils'); + + +/** + * @namespace shaka.util.EmeUtils + * @summary A set of EME utility functions. + */ + + +/** + * Gets the error message for the given key status. + * + * @param {string} status + * @return {?string} + * @see {@link https://w3c.github.io/encrypted-media/#idl-def-MediaKeyStatus} + */ +shaka.util.EmeUtils.getKeyStatusErrorMessage = function(status) { + var message = shaka.util.EmeUtils.KEY_STATUS_ERROR_MAP_[status]; + // usable, output-downscaled, and status-pending are not errors. + // The assertion helps catch future status message changes. + shaka.asserts.assert(message || + status === 'usable' || + status === 'output-downscaled' || + status === 'status-pending', + 'Unexpected key status value: ' + status); + return message || null; +}; + + +/** + * A map from key statuses to error messages. Key statuses that are not + * errors are not included in the map. + * + * @const {!Object.} + * @private + */ +shaka.util.EmeUtils.KEY_STATUS_ERROR_MAP_ = { + 'output-restricted': 'The required output protection is not available.', + // This has been removed from the EME spec and deprecated, but some browsers + // may still use it. + 'output-not-allowed': 'The required output protection is not available.', + 'expired': 'The decryption key has expired.', + 'internal-error': 'The key system has encountered an unspecified error.' +}; + diff --git a/lib/util/string_utils.js b/lib/util/string_utils.js index b8ad4f30c..8ed57222f 100644 --- a/lib/util/string_utils.js +++ b/lib/util/string_utils.js @@ -50,3 +50,16 @@ shaka.util.StringUtils.fromBase64 = function(str) { return window.atob(str.replace(/-/g, '+').replace(/_/g, '/')); }; + +/** + * Separates every 4 characters by a space. + * @param {string} str + * @return {string} + */ +shaka.util.StringUtils.formatHexString = function(str) { + return str.split('').reduce( + function(acc, ch, i) { + return acc + (i && (i % 4 == 0) ? ' ' + ch : ch); + }); +}; +