diff --git a/build/types/core b/build/types/core index 0d7e59b9b8..94d18a7991 100644 --- a/build/types/core +++ b/build/types/core @@ -28,6 +28,7 @@ +../../lib/media/playhead.js +../../lib/media/playhead_observer.js +../../lib/media/presentation_timeline.js ++../../lib/media/quality_observer.js +../../lib/media/region_observer.js +../../lib/media/region_timeline.js +../../lib/media/segment_index.js diff --git a/demo/common/message_ids.js b/demo/common/message_ids.js index a9719d1ce4..802ae86dfa 100644 --- a/demo/common/message_ids.js +++ b/demo/common/message_ids.js @@ -170,6 +170,7 @@ shakaDemo.MessageIds = { DISABLE_VIDEO: 'DEMO_DISABLE_VIDEO', DISABLE_XLINK_PROCESSING: 'DEMO_DISABLE_XLINK_PROCESSING', DISPATCH_ALL_EMSG_BOXES: 'DEMO_DISPATCH_ALL_EMSG_BOXES', + OBSERVE_QUALITY_CHANGES: 'DEMO_OBSERVE_QUALITY_CHANGES', DRM_RETRY_SECTION_HEADER: 'DEMO_DRM_RETRY_SECTION_HEADER', DRM_SECTION_HEADER: 'DEMO_DRM_SECTION_HEADER', DRM_SESSION_TYPE: 'DEMO_DRM_SESSION_TYPE', diff --git a/demo/config.js b/demo/config.js index f3d5c560fc..c29736c2d5 100644 --- a/demo/config.js +++ b/demo/config.js @@ -390,7 +390,9 @@ shakaDemo.Config = class { 'streaming.updateIntervalSeconds', /* canBeDecimal= */ true) .addBoolInput_(MessageIds.DISPATCH_ALL_EMSG_BOXES, - 'streaming.dispatchAllEmsgBoxes'); + 'streaming.dispatchAllEmsgBoxes') + .addBoolInput_(MessageIds.OBSERVE_QUALITY_CHANGES, + 'streaming.observeQualityChanges'); if (!shakaDemoMain.getNativeControlsEnabled()) { this.addBoolInput_(MessageIds.ALWAYS_STREAM_TEXT, diff --git a/demo/locales/en.json b/demo/locales/en.json index c9af9a3021..680a34cb0e 100644 --- a/demo/locales/en.json +++ b/demo/locales/en.json @@ -158,6 +158,7 @@ "DEMO_NUMBER_INTEGER_WARNING": "Must be a positive integer.", "DEMO_NUMBER_NONZERO_DECIMAL_WARNING": "Must be a positive, nonzero number.", "DEMO_NUMBER_NONZERO_INTEGER_WARNING": "Must be a positive, nonzero integer.", + "DEMO_OBSERVE_QUALITY_CHANGES": "Observe media quality changes", "DEMO_OFFLINE": "Downloadable", "DEMO_OFFLINE_SEARCH": "Filters for assets that can be stored offline.", "DEMO_OFFLINE_SECTION_HEADER": "Offline", diff --git a/demo/locales/source.json b/demo/locales/source.json index 55b76acd41..6009153570 100644 --- a/demo/locales/source.json +++ b/demo/locales/source.json @@ -639,6 +639,10 @@ "description": "A warning on number inputs, telling the user what the expected input format is.", "message": "Must be a positive, nonzero integer." }, + "DEMO_OBSERVE_QUALITY_CHANGES": { + "description": "The name of a configuration value.", + "message": "Observe media quality changes" + }, "DEMO_OFFLINE": { "description": "A tag that marks an asset as being possible to download.", "message": "Downloadable" diff --git a/externs/shaka/player.js b/externs/shaka/player.js index 4c5dfc3819..300d9469c9 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -468,6 +468,48 @@ shaka.extern.ID3Metadata; */ shaka.extern.TimelineRegionInfo; +/** + * @typedef {{ + * audioSamplingRate: ?number, + * bandwidth: number, + * codecs: string, + * contentType: string, + * frameRate: ?number, + * height: ?number, + * mimeType: ?string, + * channelsCount: ?number, + * pixelAspectRatio: ?string, + * width: ?number + * }} + * + * @description + * Contains information about the quality of an audio or video media stream. + * + * @property {?number} audioSamplingRate + * Specifies the maximum sampling rate of the content. + * @property {number} bandwidth + * The bandwidth in bits per second. + * @property {string} codecs + * The Stream's codecs, e.g., 'avc1.4d4015' or 'vp9', which must be + * compatible with the Stream's MIME type. + * @property {string} contentType + * The type of content, which may be "video" or "audio". + * @property {?number} frameRate + * The video frame rate. + * @property {?number} height + * The video height in pixels. + * @property {string} mimeType + * The MIME type. + * @property {?number} channelsCount + * The number of audio channels, or null if unknown. + * @property {?string} pixelAspectRatio + * The pixel aspect ratio value; e.g "1:1". + * @property {?number} width + * The video width in pixels. + * @exportDoc + */ +shaka.extern.MediaQualityInfo; + /** * @typedef {{ @@ -799,7 +841,8 @@ shaka.extern.ManifestConfiguration; * forceHTTPS: boolean, * preferNativeHls: boolean, * updateIntervalSeconds: number, - * dispatchAllEmsgBoxes: boolean + * dispatchAllEmsgBoxes: boolean, + * observeQualityChanges: boolean * }} * * @description @@ -910,7 +953,9 @@ shaka.extern.ManifestConfiguration; * The minimum number of seconds to see if the manifest has changes. * @property {boolean} dispatchAllEmsgBoxes * If true, all emsg boxes are parsed and dispatched. - * + * @property {boolean} observeQualityChanges + * If true, monitor media quality changes and emit + * . * @exportDoc */ shaka.extern.StreamingConfiguration; diff --git a/lib/dash/segment_base.js b/lib/dash/segment_base.js index 97c5102565..45d083a7e2 100644 --- a/lib/dash/segment_base.js +++ b/lib/dash/segment_base.js @@ -61,7 +61,9 @@ shaka.dash.SegmentBase = class { } const getUris = () => resolvedUris; - return new shaka.media.InitSegmentReference(getUris, startByte, endByte); + const qualityInfo = shaka.dash.SegmentBase.createQualityInfo(context); + return new shaka.media.InitSegmentReference( + getUris, startByte, endByte, qualityInfo); } /** @@ -346,4 +348,26 @@ shaka.dash.SegmentBase = class { indexRange.start, indexRange.end, scaledPresentationTimeOffset); } + + /** + * Create a MediaQualityInfo object from a Context object. + * + * @param {!shaka.dash.DashParser.Context} context + * @return {!shaka.extern.MediaQualityInfo} + */ + static createQualityInfo(context) { + const representation = context.representation; + return { + bandwidth: context.bandwidth, + audioSamplingRate: representation.audioSamplingRate, + codecs: representation.codecs, + contentType: representation.contentType, + frameRate: representation.frameRate || null, + height: representation.height || null, + mimeType: representation.mimeType, + channelsCount: representation.numChannels, + pixelAspectRatio: representation.pixelAspectRatio || null, + width: representation.width || null, + }; + } }; diff --git a/lib/dash/segment_template.js b/lib/dash/segment_template.js index d471837c45..f41c120936 100644 --- a/lib/dash/segment_template.js +++ b/lib/dash/segment_template.js @@ -582,8 +582,8 @@ shaka.dash.SegmentTemplate = class { baseUris, [filledTemplate]); return resolvedUris; }; - - return new shaka.media.InitSegmentReference(getUris, 0, null); + const qualityInfo = shaka.dash.SegmentBase.createQualityInfo(context); + return new shaka.media.InitSegmentReference(getUris, 0, null, qualityInfo); } }; diff --git a/lib/media/quality_observer.js b/lib/media/quality_observer.js new file mode 100644 index 0000000000..ffcee3f80a --- /dev/null +++ b/lib/media/quality_observer.js @@ -0,0 +1,291 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.media.QualityObserver'); + +goog.require('shaka.media.IPlayheadObserver'); +goog.require('shaka.log'); + +/** + * Monitors the quality of content being appended to the source + * buffers and fires onQualityChange events when the media quality + * at the playhead changes. + * + * @implements {shaka.media.IPlayheadObserver} + * @final + */ +shaka.media.QualityObserver = class { + /** + * Creates a new QualityObserver. + * + * @param {!function():!shaka.extern.BufferedInfo} getBufferedInfo + * Buffered info is needed to purge QualityChanges that are no + * longer relevant. + */ + constructor(getBufferedInfo) { + /** + * @private {!Map.} + */ + this.contentTypeStates_ = new Map(); + + /** @private {shaka.media.QualityObserver.EventListener} */ + this.onQualityChange_ = (mediaQuality, position) => {}; + + /** @private function():!shaka.extern.BufferedInfo */ + this.getBufferedInfo_ = getBufferedInfo; + } + + /** + * Set all the listeners. This overrides any previous calls to |setListeners|. + * + * @param {shaka.media.QualityObserver.EventListener} onQualityChange + */ + setListeners(onQualityChange) { + this.onQualityChange_ = onQualityChange; + } + + /** @override */ + release() { + this.contentTypeStates_.clear(); + } + + /** + * Get the ContenTypeState for a contentType, creating a new + * one if necessary. + * + * @param {!string} contentType + * The contend type e.g. "video" or "audio". + * @return {!shaka.media.QualityObserver.ContentTypeState} + * @private + */ + getContentTypeState_(contentType) { + let contentTypeState = this.contentTypeStates_.get(contentType); + if (!contentTypeState) { + contentTypeState = { + qualityChangePositions: [], + currentQuality: null, + contentType: contentType, + }; + this.contentTypeStates_.set(contentType, contentTypeState); + } + return contentTypeState; + } + + /** + * Adds a QualityChangePosition for the contentType identified by + * the mediaQuality.contentType. + * + * @param {!shaka.extern.MediaQualityInfo} mediaQuality + * @param {!number} position + * Position in seconds of the quality change. + */ + addMediaQualityChange(mediaQuality, position) { + const contentTypeState = + this.getContentTypeState_(mediaQuality.contentType); + + // Remove unneeded QualityChangePosition(s) before adding the new one + this.purgeQualityChangePositions_(contentTypeState); + + const newChangePosition = { + mediaQuality: mediaQuality, + position: position, + }; + + const changePositions = contentTypeState.qualityChangePositions; + const insertBeforeIndex = changePositions.findIndex( + (qualityChange) => (qualityChange.position >= position)); + + if (insertBeforeIndex >= 0) { + const duplicatePositions = + (changePositions[insertBeforeIndex].position == position) ? 1 : 0; + changePositions.splice( + insertBeforeIndex, duplicatePositions, newChangePosition); + } else { + changePositions.push(newChangePosition); + } + } + + /** + * Determines the media quality at a specific position in the source buffer. + * + * @param {!number} position + * Position in seconds + * @param {!shaka.media.QualityObserver.ContentTypeState} contentTypeState + * @return {?shaka.extern.MediaQualityInfo} + * @private + */ + static getMediaQualityAtPosition_(position, contentTypeState) { + // The qualityChangePositions must be ordered by position ascending + // Find the last QualityChangePosition prior to the position + const changePositions = contentTypeState.qualityChangePositions; + for (let i = changePositions.length - 1; i >= 0; i--) { + const qualityChange = changePositions[i]; + if (qualityChange.position <= position) { + return qualityChange.mediaQuality; + } + } + return null; + } + + /** + * Determines if two MediaQualityInfo objects are the same or not. + * + * @param {?shaka.extern.MediaQualityInfo} mq1 + * @param {?shaka.extern.MediaQualityInfo} mq2 + * @return {boolean} + * @private + */ + static mediaQualitiesAreTheSame_(mq1, mq2) { + if (mq1 === mq2) { + return true; + } + if (!mq1 || !mq2) { + return false; + } + return (mq1.bandwidth == mq2.bandwidth) && + (mq1.audioSamplingRate == mq2.audioSamplingRate) && + (mq1.codecs == mq2.codecs) && + (mq1.contentType == mq2.contentType) && + (mq1.frameRate == mq2.frameRate) && + (mq1.height == mq2.height) && + (mq1.mimeType == mq2.mimeType) && + (mq1.channelsCount == mq2.channelsCount) && + (mq1.pixelAspectRatio == mq2.pixelAspectRatio) && + (mq1.width == mq2.width); + } + + /** @override */ + poll(positionInSeconds, wasSeeking) { + for (const contentTypeState of this.contentTypeStates_.values()) { + const qualityAtPosition = + shaka.media.QualityObserver.getMediaQualityAtPosition_( + positionInSeconds, contentTypeState); + if (qualityAtPosition && + !shaka.media.QualityObserver.mediaQualitiesAreTheSame_( + contentTypeState.currentQuality, qualityAtPosition)) { + if (this.positionIsBuffered_( + positionInSeconds, qualityAtPosition.contentType)) { + contentTypeState.currentQuality = qualityAtPosition; + shaka.log.debug('Media quality changed at position ' + + positionInSeconds + ' ' + JSON.stringify(qualityAtPosition)); + this.onQualityChange_( + qualityAtPosition, positionInSeconds); + } + } + } + } + + /** + * Determine if a position is buffered for a given content type. + * + * @param {!number} position + * @param {!string} contentType + * @private + */ + positionIsBuffered_(position, contentType) { + const bufferedInfo = this.getBufferedInfo_(); + const bufferedRanges = bufferedInfo[contentType]; + if (bufferedRanges && bufferedRanges.length > 0) { + const bufferStart = bufferedRanges[0].start; + const bufferEnd = bufferedRanges[bufferedRanges.length - 1].end; + if (position >= bufferStart && position < bufferEnd) { + return true; + } + } + return false; + } + + /** + * Removes the QualityChangePosition(s) that are not relevant to the buffered + * content of the specified contentType. Note that this function is + * invoked just before adding the quality change info associated with + * the next media segment to be appended. + * + * @param {!shaka.media.QualityObserver.ContentTypeState} contentTypeState + * @private + */ + purgeQualityChangePositions_(contentTypeState) { + const bufferedInfo = this.getBufferedInfo_(); + const bufferedRanges = bufferedInfo[contentTypeState.contentType]; + + if (bufferedRanges && bufferedRanges.length > 0) { + const bufferStart = bufferedRanges[0].start; + const bufferEnd = bufferedRanges[bufferedRanges.length - 1].end; + const oldChangePositions = contentTypeState.qualityChangePositions; + contentTypeState.qualityChangePositions = + oldChangePositions.filter( + (qualityChange, index) => { + // Remove all but last quality change before bufferStart. + if ((qualityChange.position <= bufferStart) && + (index + 1 < oldChangePositions.length) && + (oldChangePositions[index + 1].position <= bufferStart)) { + return false; + } + // Remove all quality changes after bufferEnd. + if (qualityChange.position >= bufferEnd) { + return false; + } + return true; + }); + } else { + // Nothing is buffered; so remove all quality changes. + contentTypeState.qualityChangePositions = []; + } + } +}; + +/** + * @typedef {function(shaka.extern.MediaQualityInfo, number)} + * + * @description + * A callback function used to notify the player when media quality changes + * are detected at the playhead. + * + * The first argument is information about media quality at the playhead + * position. + * + * The second argument is the playhead position in seconds. + */ +shaka.media.QualityObserver.EventListener; + +/** + * @typedef {{ + * mediaQuality: !shaka.extern.MediaQualityInfo, + * position: !number + * }} + * + * @description + * Identifies the position of a media quality change in the + * source buffer. + * + * @property {shaka.extern.MediaQualityInfo} !mediaQuality + * The new media quality for content after position in the source buffer. + * @property {number} !position + * A position in seconds in the source buffer + */ +shaka.media.QualityObserver.QualityChangePosition; + +/** + * @typedef {{ + * qualityChangePositions: + * !Array., + * currentQuality: ?shaka.extern.MediaQualityInfo, + * contentType: !string + * }} + * + * @description + * Contains media quality information for a specific content type + * e.g video or audio. + * + * @property {!Array.} + * qualityChangePositions + * Quality changes ordered by position ascending. + * @property {?shaka.media.MediaQualityInfo} currentMediaQuality + * The media quality at the playhead position. + * @property {string} contentType + * The contentType e.g. 'video' or 'audio' + */ +shaka.media.QualityObserver.ContentTypeState; diff --git a/lib/media/segment_reference.js b/lib/media/segment_reference.js index 866e1d8d06..a11f6b6de7 100644 --- a/lib/media/segment_reference.js +++ b/lib/media/segment_reference.js @@ -23,11 +23,13 @@ shaka.media.InitSegmentReference = class { * of the resource containing the segment. * @param {number} startByte The offset from the start of the resource to the * start of the segment. - * @param {?number} endByte The offset from the start of the resource to the - * end of the segment, inclusive. A value of null indicates that the + * @param {?number} endByte The offset from the start of the resource + * to the end of the segment, inclusive. A value of null indicates that the * segment extends to the end of the resource. + * @param {null|shaka.extern.MediaQualityInfo=} mediaQuality Information about + * the quality of the media associated with this init segment. */ - constructor(uris, startByte, endByte) { + constructor(uris, startByte, endByte, mediaQuality = null) { /** @type {function():!Array.} */ this.getUris = uris; @@ -36,6 +38,9 @@ shaka.media.InitSegmentReference = class { /** @const {?number} */ this.endByte = endByte; + + /** @const {shaka.extern.MediaQualityInfo|null} */ + this.mediaQuality = mediaQuality; } /** @@ -73,6 +78,16 @@ shaka.media.InitSegmentReference = class { } } + /** + * Returns media quality information for the segments associated with + * this init segment. + * + * @return {?shaka.extern.MediaQualityInfo} + */ + getMediaQuality() { + return this.mediaQuality; + } + /** * Check if two initSegmentReference have all the same values. * @param {?shaka.media.InitSegmentReference} reference1 diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index 47d489c008..863ca9c872 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -1541,6 +1541,8 @@ shaka.media.StreamingEngine = class { throw error; } }; + this.playerInterface_.onInitSegmentAppended( + reference.startTime, reference.initSegmentReference); operations.push(append()); } } @@ -1961,7 +1963,8 @@ shaka.media.StreamingEngine = class { * onError: function(!shaka.util.Error), * onEvent: function(!Event), * onManifestUpdate: function(), - * onSegmentAppended: function() + * onSegmentAppended: function(), + * onInitSegmentAppended: function(!number,!shaka.media.InitSegmentReference) * }} * * @property {function():number} getPresentationTime @@ -1986,6 +1989,9 @@ shaka.media.StreamingEngine = class { * Called when an embedded 'emsg' box should trigger a manifest update. * @property {function()} onSegmentAppended * Called after a segment is successfully appended to a MediaSource. + * @property + * {function(!number, !shaka.media.InitSegmentReference)} onInitSegmentAppended + * Called when an init segment is appended to a MediaSource. */ shaka.media.StreamingEngine.PlayerInterface; diff --git a/lib/player.js b/lib/player.js index 9a5bc6425e..c3fbf3fca7 100644 --- a/lib/player.js +++ b/lib/player.js @@ -22,6 +22,7 @@ goog.require('shaka.media.PlayRateController'); goog.require('shaka.media.Playhead'); goog.require('shaka.media.PlayheadObserverManager'); goog.require('shaka.media.PreferenceBasedCriteria'); +goog.require('shaka.media.QualityObserver'); goog.require('shaka.media.RegionObserver'); goog.require('shaka.media.RegionTimeline'); goog.require('shaka.media.SegmentIndex'); @@ -174,6 +175,21 @@ goog.requireType('shaka.routing.Payload'); * @exportDoc */ +/** + * @event shaka.Player.MediaQualityChangedEvent + * @description Fired when the media quality changes at the playhead. + * That may be caused by an adaptation change or a DASH period transition. + * Separate events are emitted for audio and video contentTypes. + * This is supported for only DASH streams at this time. + * @property {string} type + * 'mediaqualitychanged' + * @property {shaka.extern.MediaQualityInfo} mediaQuality + * Information about media quality at the playhead position. + * @property {number} position + * The playhead position. + * @exportDoc + */ + /** * @event shaka.Player.BufferingEvent @@ -471,6 +487,9 @@ shaka.Player = class extends shaka.util.FakeEventTarget { /** @private {shaka.util.CmcdManager} */ this.cmcdManager_ = null; + /** @private {shaka.media.QualityObserver} */ + this.qualityObserver_ = null; + /** @private {shaka.media.StreamingEngine} */ this.streamingEngine_ = null; @@ -1678,6 +1697,14 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.adManager_.onDashTimedMetadata(region); } }); + this.qualityObserver_ = null; + if (this.config_.streaming.observeQualityChanges) { + this.qualityObserver_ = new shaka.media.QualityObserver( + () => this.getBufferedInfo()); + this.qualityObserver_.setListeners((mediaQualityInfo, position) => { + this.onMediaQualityChange_(mediaQualityInfo, position); + }); + } const playerInterface = { networkingEngine: networkingEngine, @@ -2666,7 +2693,9 @@ shaka.Player = class extends shaka.util.FakeEventTarget { // Now that we have all our observers, create a manager for them. const manager = new shaka.media.PlayheadObserverManager(this.video_); manager.manage(regionObserver); - + if (this.qualityObserver_) { + manager.manage(this.qualityObserver_); + } return manager; } @@ -2828,6 +2857,12 @@ shaka.Player = class extends shaka.util.FakeEventTarget { onEvent: (event) => this.dispatchEvent(event), onManifestUpdate: () => this.onManifestUpdate_(), onSegmentAppended: () => this.onSegmentAppended_(), + onInitSegmentAppended: (position, initSegment) => { + const mediaQuality = initSegment.getMediaQuality(); + if (mediaQuality && this.qualityObserver_) { + this.qualityObserver_.addMediaQualityChange(mediaQuality, position); + } + }, }; return new shaka.media.StreamingEngine(this.manifest_, playerInterface); @@ -5634,6 +5669,38 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.dispatchEvent(this.makeEvent_(eventName, data)); } + /** + * When notified of a media quality change we need to emit a + * MediaQualityChange event to the app. + * + * @param {shaka.extern.MediaQualityInfo} mediaQuality + * @param {number} position + * + * @private + */ + onMediaQualityChange_(mediaQuality, position) { + // Always make a copy to avoid exposing our internal data to the app. + const clone = { + bandwidth: mediaQuality.bandwidth, + audioSamplingRate: mediaQuality.audioSamplingRate, + codecs: mediaQuality.codecs, + contentType: mediaQuality.contentType, + frameRate: mediaQuality.frameRate, + height: mediaQuality.height, + mimeType: mediaQuality.mimeType, + channelsCount: mediaQuality.channelsCount, + pixelAspectRatio: mediaQuality.pixelAspectRatio, + width: mediaQuality.width, + }; + + const data = new Map() + .set('mediaQuality', clone) + .set('position', position); + + this.dispatchEvent(this.makeEvent_( + shaka.Player.EventName.MediaQualityChanged, data)); + } + /** * Turn the media element's error object into a Shaka Player error object. * @@ -6407,6 +6474,7 @@ shaka.Player.EventName = { Loaded: 'loaded', Loading: 'loading', ManifestParsed: 'manifestparsed', + MediaQualityChanged: 'mediaqualitychanged', Metadata: 'metadata', OnStateChange: 'onstatechange', OnStateIdle: 'onstateidle', diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js index 3851e64b14..b10432481e 100644 --- a/lib/util/player_configuration.js +++ b/lib/util/player_configuration.js @@ -166,6 +166,7 @@ shaka.util.PlayerConfiguration = class { preferNativeHls: false, updateIntervalSeconds: 1, dispatchAllEmsgBoxes: false, + observeQualityChanges: false, }; // Some browsers will stop earlier than others before a gap (e.g., Edge diff --git a/test/media/quality_observer_unit.js b/test/media/quality_observer_unit.js new file mode 100644 index 0000000000..f37c6d1a28 --- /dev/null +++ b/test/media/quality_observer_unit.js @@ -0,0 +1,139 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.require('shaka.media.QualityObserver'); +goog.require('shaka.test.Util'); + + +describe('QualityObserver', () => { + /** @type {!shaka.media.QualityObserver} */ + let observer; + + /** @type {!jasmine.Spy} */ + let onQualityChange; + + const createQualityInfo = (contentType, bandwidth) => { + return { + bandwidth: bandwidth, + audioSamplingRate: 444000, + codecs: 'my codec', + contentType: contentType, + frameRate: 30, + height: 720, + mimeType: 'mime type', + channelsCount: 2, + pixelAspectRatio: '1:1', + width: 1280, + }; + }; + let emptyBuffer = true; + let bufferStart = 0; + let bufferEnd = 0; + + const quality1 = createQualityInfo('video', 1); + const quality2 = createQualityInfo('video', 2); + + const getBufferedInfo = () => { + if (emptyBuffer) { + return { + video: [], + }; + } + return { + video: [{start: bufferStart, end: bufferEnd}], + }; + }; + + beforeEach(() => { + onQualityChange = jasmine.createSpy('onQualityChange'); + observer = new shaka.media.QualityObserver(getBufferedInfo); + observer.setListeners(shaka.test.Util.spyFunc(onQualityChange)); + emptyBuffer = true; + bufferStart = 0; + bufferEnd = 0; + }); + + it('does not call onQualityChange when there are no quality changes', () => { + observer.poll(10, false); + expect(onQualityChange).not.toHaveBeenCalled(); + }); + + it('calls onQualityChange when position is after 1st quality change', () => { + observer.addMediaQualityChange(quality1, 10); + emptyBuffer = false; + bufferStart = 10; + bufferEnd = 20; + observer.poll(10, false); + expect(onQualityChange).toHaveBeenCalledWith(quality1, 10); + }); + + it('does not call onQualityChange when pos advances with no change', () => { + observer.addMediaQualityChange(quality1, 10); + emptyBuffer = false; + bufferStart = 10; + bufferEnd = 20; + observer.poll(10, false); + expect(onQualityChange).toHaveBeenCalledWith(quality1, 10); + observer.poll(11, false); + expect(onQualityChange).toHaveBeenCalledTimes(1); + }); + + it('does not call onQualityChange on seek to unbuffered position', () => { + observer.addMediaQualityChange(quality1, 10); + emptyBuffer = false; + bufferStart = 10; + bufferEnd = 20; + observer.poll(15, false); + expect(onQualityChange).toHaveBeenCalledOnceMoreWith([quality1, 15]); + observer.addMediaQualityChange(quality2, 20); + observer.poll(25, true); + expect(onQualityChange).not.toHaveBeenCalledOnceMore(); + bufferEnd = 30; + observer.poll(26, false); + expect(onQualityChange).toHaveBeenCalledOnceMoreWith([quality2, 26]); + }); + + it('calls onQualityChange when position advances over 2nd quality change', + () => { + observer.addMediaQualityChange(quality1, 10); + emptyBuffer = false; + bufferStart = 10; + bufferEnd = 20; + observer.poll(10, false); + expect(onQualityChange).toHaveBeenCalledOnceMoreWith([quality1, 10]); + observer.addMediaQualityChange(quality2, 20); + bufferStart = 10; + bufferEnd = 30; + observer.poll(20, false); + expect(onQualityChange).toHaveBeenCalledOnceMoreWith([quality2, 20]); + }); + + it('calls onQualityChange when position moves back over a quality chanage', + () => { + observer.addMediaQualityChange(quality1, 10); + emptyBuffer = false; + bufferStart = 10; + bufferEnd = 20; + observer.addMediaQualityChange(quality2, 20); + bufferStart = 10; + bufferEnd = 30; + observer.poll(25, false); + expect(onQualityChange).toHaveBeenCalledOnceMoreWith([quality2, 25]); + observer.poll(15, false); + expect(onQualityChange).toHaveBeenCalledOnceMoreWith([quality1, 15]); + }); + + it('uses last applied quality when there are two at the same position', + () => { + observer.addMediaQualityChange(quality1, 10); + observer.addMediaQualityChange(quality2, 10); + emptyBuffer = false; + bufferStart = 10; + bufferEnd = 20; + observer.poll(15, false); + expect(onQualityChange).toHaveBeenCalledWith(quality2, 15); + }); +}); diff --git a/test/media/segment_reference_unit.js b/test/media/segment_reference_unit.js index 718e57ba67..25c456f16a 100644 --- a/test/media/segment_reference_unit.js +++ b/test/media/segment_reference_unit.js @@ -38,14 +38,29 @@ describe('SegmentReference', () => { }); describe('InitSegmentReference', () => { + const mediaQuality = { + bandwidth: 1, + audioSamplingRate: 444000, + codecs: 'my codec', + contentType: 'video', + frameRate: 30, + height: 720, + mimeType: 'mime type', + channelsCount: 2, + pixelAspectRatio: '1:1', + width: 1280, + }; + it('returns in getters values from constructor parameters', () => { const reference = new shaka.media.InitSegmentReference( /* getUris= */ () => ['x', 'y'], /* startByte= */ 4, - /* endByte= */ 5); + /* endByte= */ 5, + mediaQuality); expect(reference.getUris()).toEqual(['x', 'y']); expect(reference.getStartByte()).toBe(4); expect(reference.getEndByte()).toBe(5); + expect(reference.getMediaQuality()).toBe(mediaQuality); }); }); diff --git a/test/media/streaming_engine_integration.js b/test/media/streaming_engine_integration.js index 1b48fad0ba..96a93b920d 100644 --- a/test/media/streaming_engine_integration.js +++ b/test/media/streaming_engine_integration.js @@ -270,6 +270,7 @@ describe('StreamingEngine', () => { onEvent: Util.spyFunc(onEvent), onManifestUpdate: () => {}, onSegmentAppended: () => playhead.notifyOfBufferingChange(), + onInitSegmentAppended: () => {}, }; streamingEngine = new shaka.media.StreamingEngine( /** @type {shaka.extern.Manifest} */(manifest), playerInterface); diff --git a/test/media/streaming_engine_unit.js b/test/media/streaming_engine_unit.js index 2bb312ccad..3f17b8f300 100644 --- a/test/media/streaming_engine_unit.js +++ b/test/media/streaming_engine_unit.js @@ -460,6 +460,7 @@ describe('StreamingEngine', () => { onEvent: Util.spyFunc(onEvent), onManifestUpdate: Util.spyFunc(onManifestUpdate), onSegmentAppended: Util.spyFunc(onSegmentAppended), + onInitSegmentAppended: () => {}, }; streamingEngine = new shaka.media.StreamingEngine( /** @type {shaka.extern.Manifest} */(manifest), playerInterface); diff --git a/test/player_integration.js b/test/player_integration.js index 0560485174..04bc7c0b38 100644 --- a/test/player_integration.js +++ b/test/player_integration.js @@ -687,6 +687,69 @@ describe('Player', () => { }); }); + describe('mediaQualityChanges', () => { + /** @type {!jasmine.Spy} */ + let onQualityChange; + /** @type {!shaka.test.Waiter} */ + let waiter; + + const qualityChange1 = jasmine.objectContaining({ + mediaQuality: jasmine.objectContaining({ + bandwidth: 1, + codecs: 'mp4a.40.2', + contentType: 'audio', + mimeType: 'audio/mp4', + }), + type: 'mediaqualitychanged', + position: jasmine.any(Number), + }); + const qualityChange2 = jasmine.objectContaining({ + mediaQuality: jasmine.objectContaining({ + bandwidth: 1, + codecs: 'avc1.42c01e', + contentType: 'video', + mimeType: 'video/mp4', + }), + type: 'mediaqualitychanged', + position: jasmine.any(Number), + }); + + beforeEach(() => { + onQualityChange = jasmine.createSpy('onQualityChange'); + // const spyFunc = Util.spyFunc(onQualityChange); + player.addEventListener('mediaqualitychanged', + Util.spyFunc(onQualityChange)); + waiter = new shaka.test.Waiter(eventManager) + .timeoutAfter(10) + .failOnTimeout(true); + }); + + it('emits audio/video quality changes at start when enabled', async () => { + player.configure('streaming.observeQualityChanges', true); + + await player.load('test:sintel_compiled'); + video.play(); + + // Wait for the video to start playback. If it takes longer than 10 + // seconds, fail the test. + await waiter.waitForMovementOrFailOnTimeout(video, 10); + expect(onQualityChange).toHaveBeenCalledTimes(2); + expect(onQualityChange).toHaveBeenCalledWith(qualityChange1); + expect(onQualityChange).toHaveBeenCalledWith(qualityChange2); + }); + it('does not emit quality changes at start when disabled', async () => { + player.configure('streaming.observeQualityChanges', false); + + await player.load('test:sintel_compiled'); + video.play(); + + // Wait for the video to start playback. If it takes longer than 10 + // seconds, fail the test. + await waiter.waitForMovementOrFailOnTimeout(video, 10); + expect(onQualityChange).not.toHaveBeenCalled(); + }); + }); + describe('buffering', () => { const startBuffering = jasmine.objectContaining({buffering: true}); const endBuffering = jasmine.objectContaining({buffering: false}); diff --git a/test/test/util/manifest_generator.js b/test/test/util/manifest_generator.js index 91fcd7f193..89392d4712 100644 --- a/test/test/util/manifest_generator.js +++ b/test/test/util/manifest_generator.js @@ -653,15 +653,16 @@ shaka.test.ManifestGenerator.Stream = class { * @param {!Array.} uris * @param {number} startByte * @param {?number} endByte + * @param {null|shaka.extern.MediaQualityInfo=} mediaQuality */ - setInitSegmentReference(uris, startByte, endByte) { + setInitSegmentReference(uris, startByte, endByte, mediaQuality) { goog.asserts.assert(this.manifest_, 'A top-level generated Manifest is required to use this method!'); const getUris = () => uris; this.initSegmentReference_ = new this.manifest_.shaka_.media.InitSegmentReference( - getUris, startByte, endByte); + getUris, startByte, endByte, mediaQuality); } /** diff --git a/test/test/util/test_scheme.js b/test/test/util/test_scheme.js index d8420b1973..c6d4b59269 100644 --- a/test/test/util/test_scheme.js +++ b/test/test/util/test_scheme.js @@ -206,10 +206,23 @@ shaka.test.TestScheme = class { * @param {string} name */ function addStreamInfo(stream, variant, data, contentType, name) { + const mediaQualityInfo = { + bandwidth: 1, + codecs: data[contentType].codecs || 'unknown', + contentType: contentType, + mimeType: data[contentType].mimeType, + audioSamplingRate: null, + frameRate: null, + height: null, + channelsCount: null, + pixelAspectRatio: null, + width: null, + }; stream.mimeType = data[contentType].mimeType; stream.codecs = data[contentType].codecs; stream.setInitSegmentReference( - ['test:' + name + '/' + contentType + '/init'], 0, null); + ['test:' + name + '/' + contentType + '/init'], 0, null, + mediaQualityInfo); stream.useSegmentTemplate( 'test:' + name + '/' + contentType + '/%d', data[contentType].segmentDuration);