/*! @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');
goog.require('shaka.util.ArrayUtils');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.ManifestParserUtils');
/**
* Monitors the quality of content being appended to the source buffers and
* fires 'qualitychange' events when the media quality at the playhead changes.
*
* @implements {shaka.media.IPlayheadObserver}
* @final
*/
shaka.media.QualityObserver = class extends shaka.util.FakeEventTarget {
/**
* Creates a new QualityObserver.
*
* @param {!function():!shaka.extern.BufferedInfo} getBufferedInfo
* Buffered info is needed to purge QualityChanges that are no
* longer relevant.
*/
constructor(getBufferedInfo) {
super();
/**
* @private {!Map.<string,!shaka.media.QualityObserver.ContentTypeState>}
*/
this.contentTypeStates_ = new Map();
/** @private function():!shaka.extern.BufferedInfo */
this.getBufferedInfo_ = getBufferedInfo;
}
/** @override */
release() {
this.contentTypeStates_.clear();
super.release();
}
/**
* 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 currentQuality = contentTypeState.currentQuality;
const qualityAtPosition =
shaka.media.QualityObserver.getMediaQualityAtPosition_(
positionInSeconds, contentTypeState);
const differentQualities = qualityAtPosition &&
!shaka.media.QualityObserver.mediaQualitiesAreTheSame_(
currentQuality, qualityAtPosition);
const differentLabel = qualityAtPosition && currentQuality &&
qualityAtPosition.label && currentQuality.label &&
currentQuality.label !== qualityAtPosition.label;
const differentLanguage = qualityAtPosition && currentQuality &&
qualityAtPosition.language && currentQuality.language &&
currentQuality.language !== qualityAtPosition.language;
const differentRoles = qualityAtPosition && currentQuality &&
qualityAtPosition.roles && currentQuality.roles &&
!shaka.util.ArrayUtils.equal(currentQuality.roles,
qualityAtPosition.roles);
if (differentLabel || differentLanguage || differentRoles) {
if (this.positionIsBuffered_(
positionInSeconds, qualityAtPosition.contentType)) {
contentTypeState.currentQuality = qualityAtPosition;
const event = new shaka.util.FakeEvent('audiotrackchange', new Map([
['quality', qualityAtPosition],
['position', positionInSeconds],
]));
this.dispatchEvent(event);
}
}
if (differentQualities) {
if (this.positionIsBuffered_(
positionInSeconds, qualityAtPosition.contentType)) {
contentTypeState.currentQuality = qualityAtPosition;
shaka.log.debug('Media quality changed at position ' +
positionInSeconds + ' ' + JSON.stringify(qualityAtPosition));
const event = new shaka.util.FakeEvent('qualitychange', new Map([
['quality', qualityAtPosition],
['position', positionInSeconds],
]));
this.dispatchEvent(event);
}
}
}
}
/**
* 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 = [];
}
}
/**
* Create a MediaQualityInfo object from a stream object.
*
* @param {!shaka.extern.Stream} stream
* @return {!shaka.extern.MediaQualityInfo}
*/
static createQualityInfo(stream) {
const basicQuality = {
bandwidth: stream.bandwidth || 0,
audioSamplingRate: null,
codecs: stream.codecs,
contentType: stream.type,
frameRate: null,
height: null,
mimeType: stream.mimeType,
channelsCount: null,
pixelAspectRatio: null,
width: null,
label: null,
roles: stream.roles,
language: null,
};
if (stream.type == shaka.util.ManifestParserUtils.ContentType.VIDEO) {
basicQuality.frameRate = stream.frameRate || null;
basicQuality.height = stream.height || null;
basicQuality.pixelAspectRatio = stream.pixelAspectRatio || null;
basicQuality.width = stream.width || null;
}
if (stream.type == shaka.util.ManifestParserUtils.ContentType.AUDIO) {
basicQuality.audioSamplingRate = stream.audioSamplingRate;
basicQuality.channelsCount = stream.channelsCount;
basicQuality.label = stream.label || null;
basicQuality.language = stream.language;
}
return basicQuality;
}
};
/**
* @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.<shaka.media.QualityObserver.QualityChangePosition>,
* currentQuality: ?shaka.extern.MediaQualityInfo,
* contentType: !string
* }}
*
* @description
* Contains media quality information for a specific content type
* e.g. video or audio.
*
* @property {!Array.<shaka.media.QualityObserver.QualityChangePosition>}
* 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;