feat(CMCD): Add event mode support (#8980)

This pull request introduces comprehensive support for the CMCDv2
"event" mode in Shaka Player. This new functionality allows the player
to send real-time Common Media Client Data (CMCD) based on various
player and media events

- CMCDv2 Event Mode Implementation: Partial support for CMCDv2's "event"
mode has been implemented, enabling real-time event data reporting from
the player.
- Configurable Event Reporting: Configuration options have been
introduced to specify which CMCD keys and player events (e.g., play
state, mute/unmute, fullscreen, background mode) are included in the
reports.
- Periodic Time Interval Events: A timeInterval configuration option has
been added for periodic CMCD event reports, with a default of 10 seconds
and the ability to disable by setting it to 0.
- Unit Testing: Extensive unit tests have been added to validate the new
CMCDv2 event mode functionality across various scenarios, including
event filtering, header usage, and handling of multiple targets.

Shaka Player config for testing event mode:
```js
const cmcdConfig = {
    enabled: false,
    version: 2,
    contentId: 'id',
    useHeaders: false,
    targets: [{
        mode: 'response',
        enabled: false,
        useHeaders: false,
        url: 'http://localhost:3003/response-mode',
    },{
        mode: 'event',
        useHeaders: false,
        url: 'http://localhost:3003/event-mode',
        includeKeys: [],
        enabled: true,
    }]
}

player.configure('cmcd', cmcdConfig);
```

---------

Co-authored-by: Constanza Dibueno <121617928+cotid-qualabs@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Álvaro Velad Galván <ladvan91@hotmail.com>
This commit is contained in:
Juan Manuel Gonzalez
2025-08-25 04:37:09 -03:00
committed by GitHub
parent f77888bd6f
commit c33e19e2f7
5 changed files with 1649 additions and 178 deletions
+12 -1
View File
@@ -2499,7 +2499,9 @@ shaka.extern.AdvancedAbrConfiguration;
* enabled: boolean,
* useHeaders: boolean,
* url: string,
* includeKeys: !Array<string>
* includeKeys: !Array<string>,
* events: !Array<string>,
* timeInterval: number,
* }}
*
* @description
@@ -2530,6 +2532,15 @@ shaka.extern.AdvancedAbrConfiguration;
* If not provided, all keys will be included.
* <br>
* Defaults to <code>[]</code>.
* @property {!Array<string>} events
* An array of events to include as part of ps and sta in the CMCD data.
* If not provided, all events will be included.
* <br>
* Defaults to <code>[]</code>.
* @property {number} timeInterval
* Time Interval config in seconds
* <br>
* Defaults to <code>10</code>.
* @exportDoc
*/
shaka.extern.CmcdTarget;
+2 -1
View File
@@ -328,7 +328,8 @@ shaka.cast.CastUtils.PlayerGetterMethods = new Map()
.set('getLoadMode', 10)
.set('getManifestType', 10)
.set('isFullyLoaded', 1)
.set('isEnded', 1);
.set('isEnded', 1)
.set('getBandwidthEstimate', 1);
/**
+11 -14
View File
@@ -4130,20 +4130,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
* @private
*/
createCmcd_() {
/** @type {shaka.util.CmcdManager.PlayerInterface} */
const playerInterface = {
getBandwidthEstimate: () => this.abrManager_ ?
this.abrManager_.getBandwidthEstimate() : NaN,
getBufferedInfo: () => this.getBufferedInfo(),
getCurrentTime: () => this.video_ ? this.video_.currentTime : 0,
getPlaybackRate: () => this.getPlaybackRate(),
getNetworkingEngine: () => this.getNetworkingEngine(),
getVariantTracks: () => this.getVariantTracks(),
isLive: () => this.isLive(),
getLiveLatency: () => this.getLiveLatency(),
};
return new shaka.util.CmcdManager(playerInterface, this.config_.cmcd);
return new shaka.util.CmcdManager(this, this.config_.cmcd);
}
/**
@@ -6830,6 +6817,16 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
return Math.floor(Date.now() - now);
}
/**
* Get current player time.
*
* @return {!number}
*/
getBandwidthEstimate() {
return this.abrManager_ ?
this.abrManager_.getBandwidthEstimate() : NaN;
}
/**
* Get statistics for the current playback session. If the player is not
* playing content, this will return an empty stats object.
+314 -64
View File
@@ -12,8 +12,10 @@ goog.require('shaka.log');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.ArrayUtils');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.Timer');
goog.requireType('shaka.media.SegmentReference');
goog.requireType('shaka.Player');
/**
* @summary
@@ -22,16 +24,16 @@ goog.requireType('shaka.media.SegmentReference');
*/
shaka.util.CmcdManager = class {
/**
* @param {shaka.util.CmcdManager.PlayerInterface} playerInterface
* @param {shaka.Player} player
* @param {shaka.extern.CmcdConfiguration} config
*/
constructor(playerInterface, config) {
/** @private {shaka.util.CmcdManager.PlayerInterface} */
this.playerInterface_ = playerInterface;
constructor(player, config) {
/** @private {?shaka.extern.CmcdConfiguration} */
this.config_ = config;
/** @private {?shaka.Player} */
this.player_ = player;
/** @private {!Map<!shaka.extern.Request, number>} */
this.requestTimestampMap_ = new Map();
@@ -78,11 +80,12 @@ shaka.util.CmcdManager = class {
this.startTimeOfLoad_ = 0;
/**
* @private {{request: boolean, response: boolean}}
* @private {{request: boolean, response: boolean, event: boolean}}
*/
this.msdSent_ = {
request: false,
response: false,
event: false,
};
/**
@@ -95,6 +98,9 @@ shaka.util.CmcdManager = class {
*/
this.eventManager_ = new shaka.util.EventManager();
/** @private {Array<shaka.util.Timer>} */
this.eventTimers_ = [];
/** @private {HTMLMediaElement} */
this.video_ = null;
}
@@ -117,6 +123,7 @@ shaka.util.CmcdManager = class {
*/
configure(config) {
this.config_ = config;
this.setupEventModeTimeInterval_();
}
@@ -135,8 +142,10 @@ shaka.util.CmcdManager = class {
this.msdSent_ = {
request: false,
response: false,
event: false,
};
this.stopAndClearEventTimers_();
this.cmcdSequenceNumbers_ = {};
this.video_ = null;
@@ -155,6 +164,7 @@ shaka.util.CmcdManager = class {
if (this.playbackStarted_ && buffering) {
this.starved_ = true;
this.reportEvent_('ps', {sta: 'r'});
}
this.buffering_ = buffering;
@@ -193,6 +203,8 @@ shaka.util.CmcdManager = class {
if (!this.config_ || !this.config_.enabled) {
return;
}
this.reportEvent_('ps', {sta: 'd'});
if (this.video_ && this.video_.autoplay) {
const playResult = this.video_.play();
if (playResult) {
@@ -467,6 +479,7 @@ shaka.util.CmcdManager = class {
onPlaybackPlay_() {
if (!this.playbackPlayTime_) {
this.playbackPlayTime_ = Date.now();
this.reportEvent_('ps', {sta: 's'});
}
}
@@ -485,13 +498,143 @@ shaka.util.CmcdManager = class {
* @private
*/
setupEventListeners_() {
const onPlaybackPlay = () => this.onPlaybackPlay_();
this.eventManager_.listenOnce(
this.video_, 'play', onPlaybackPlay);
this.eventManager_.listen(
this.video_, 'playing', () => {
this.onPlaybackPlaying_();
this.reportEvent_('ps', {sta: 'p'});
},
);
const onPlaybackPlaying = () => this.onPlaybackPlaying_();
this.eventManager_.listenOnce(
this.video_, 'playing', onPlaybackPlaying);
// Mute/Unmute
this.eventManager_.listen(this.video_, 'volumechange', () => {
this.reportEvent_(this.video_.muted ? 'm' : 'um');
});
// Play
this.eventManager_.listen(this.video_, 'play', () => {
this.onPlaybackPlay_();
});
// Pause
this.eventManager_.listen(this.video_, 'pause', () => {
this.reportEvent_('ps', {sta: 'a'});
});
// Waiting
this.eventManager_.listen(this.player_, 'buffering', () => {
this.reportEvent_('ps', {sta: 'w'});
});
// Seeking
this.eventManager_.listen(this.video_, 'seeking', () =>
this.reportEvent_('ps', {sta: 'k'}),
);
// Fullscreen Change (Player Expand/Collapse)
this.eventManager_.listen(this.video_, 'fullscreenchange', () => {
const isFullScreen = !!document.fullscreenElement;
this.reportEvent_(isFullScreen ? 'pe' : 'pc');
});
if (this.video_.webkitPresentationMode) {
this.eventManager_.listen(this.video_,
'webkitpresentationmodechanged', () => {
if (this.video_.webkitPresentationMode) {
this.reportEvent_(
this.video_.webkitPresentationMode !== 'inline' ?'pe' : 'pc');
}
});
}
this.eventManager_.listen(this.video_, 'enterpictureinpicture', () => {
this.reportEvent_('pe');
});
this.eventManager_.listen(this.video_, 'leavepictureinpicture', () => {
this.reportEvent_('pc');
});
if ('documentPictureInPicture' in window) {
this.eventManager_.listen(window.documentPictureInPicture, 'enter',
(e) => {
this.reportEvent_('pe');
const event = /** @type {DocumentPictureInPictureEvent} */(e);
const pipWindow = event.window;
this.eventManager_.listenOnce(pipWindow, 'pagehide', () => {
this.reportEvent_('pc');
});
});
}
// Background Mode
this.eventManager_.listen(document, 'visibilitychange', () => {
if (document.hidden) {
this.reportEvent_('b', {bg: true});
} else {
this.reportEvent_('b');
}
});
this.eventManager_.listen(this.player_, 'complete', () => {
this.reportEvent_('ps', {sta: 'e'});
});
}
/**
* Sets up TimeInterval timer for CMCD 'EVENT' mode targets.
* @private
*/
setupEventModeTimeInterval_() {
this.stopAndClearEventTimers_();
const eventTargets = this.getEventModeEnabledTargets_();
for (const target of eventTargets) {
let timeInterval = target.timeInterval;
// Checking for `timeInterval === undefined` since
// timeInterval = 0 is used to turn TimeInterval off
if (timeInterval === undefined) {
timeInterval =
shaka.util.CmcdManager.CmcdV2Constants.TIME_INTERVAL_DEFAULT_VALUE;
}
if (timeInterval >= 1) {
const eventModeTimer = new shaka.util.Timer(
() => this.reportEvent_(
shaka.util.CmcdManager.CmcdV2Keys.TIME_INTERVAL_EVENT));
eventModeTimer.tickEvery(timeInterval);
this.eventTimers_.push(eventModeTimer);
}
}
}
/**
* Stops and clears all the event timers for timeInterval
* @private
*/
stopAndClearEventTimers_() {
if (this.eventTimers_) {
for (const timer of this.eventTimers_) {
timer.stop();
}
}
this.eventTimers_ = [];
}
/**
* @return {!Array<shaka.extern.CmcdTarget>}
* @private
*/
getEventModeEnabledTargets_() {
const targets = this.config_.targets;
if (!targets) {
return [];
}
return targets.filter(
(target) => target.mode === shaka.util.CmcdManager.CmcdMode.EVENT &&
target.enabled);
}
/**
@@ -509,10 +652,64 @@ shaka.util.CmcdManager = class {
sf: this.sf_,
sid: this.config_.sessionId,
cid: this.config_.contentId,
mtp: this.playerInterface_.getBandwidthEstimate() / 1000,
mtp: this.player_.getBandwidthEstimate() / 1000,
};
}
/**
* @param {string} eventType
* @param {CmcdData} extraData
* @private
*/
reportEvent_(eventType, extraData = {}) {
const baseEventData = {
e: eventType,
ts: Date.now(),
};
const eventData = Object.assign(baseEventData, extraData);
const rawOutput = this.getGenericData_(eventData,
shaka.util.CmcdManager.CmcdMode.EVENT);
const version = this.config_.version;
const targets = this.config_.targets;
if (version < shaka.util.CmcdManager.Version.VERSION_2 || !targets) {
return;
}
const eventTargets = this.getEventModeEnabledTargets_();
const allowedKeys = Array.from(new Set([
...shaka.util.CmcdManager.CmcdKeys.V2CommonKeys,
...shaka.util.CmcdManager.CmcdKeys.V2EventKeys,
]));
for (const target of eventTargets) {
const includeKeys = target.includeKeys || [];
const allowedKeysEventMode = this.checkValidKeys_(
includeKeys,
allowedKeys,
shaka.util.CmcdManager.CmcdMode.EVENT,
);
if (!allowedKeysEventMode.includes(
shaka.util.CmcdManager.CmcdV2Keys.TIMESTAMP)) {
allowedKeysEventMode.push(shaka.util.CmcdManager.CmcdV2Keys.TIMESTAMP);
}
const output = this.filterKeys_(rawOutput, allowedKeysEventMode);
const includeEvents = target.events || [];
if (!this.isValidEvent_(includeEvents, output)) {
continue;
}
this.sendCmcdRequest_(output, target);
}
}
/**
* Apply CMCD data to a request.
*
@@ -664,7 +861,7 @@ shaka.util.CmcdManager = class {
request = shaka.net.NetworkingEngine.makeRequest([finalUri], retryParams);
}
const requestType = shaka.net.NetworkingEngine.RequestType.CMCD;
const networkingEngine = this.playerInterface_.getNetworkingEngine();
const networkingEngine = this.player_.getNetworkingEngine();
networkingEngine.request(requestType, request);
}
@@ -746,6 +943,40 @@ shaka.util.CmcdManager = class {
}, {});
}
/**
* @param {Array<string>} includeEvents
* @param {CmcdData} data
* @private
*
* @return {boolean}
*/
isValidEvent_(includeEvents, data) {
const allowedEvents = shaka.util.CmcdManager.CmcdKeys.CmcdV2Events;
const allowedPlayStates = shaka.util.CmcdManager.CmcdKeys.CmcdV2PlayStates;
const event = data['e'];
const playState = data['sta'];
if (event) {
if (!allowedEvents.includes(event)) {
return false;
}
if (event === 'ps') {
if (!playState || !allowedPlayStates.includes(playState)) {
return false;
}
}
if (includeEvents && includeEvents.length > 0 &&
!includeEvents.includes(event)) {
return false;
}
}
return true;
}
/**
* The CMCD object type.
*
@@ -854,13 +1085,13 @@ shaka.util.CmcdManager = class {
* @private
*/
getBufferLength_(type) {
const ranges = this.playerInterface_.getBufferedInfo()[type];
const ranges = this.player_.getBufferedInfo()[type];
if (!ranges.length) {
return NaN;
}
const start = this.playerInterface_.getCurrentTime();
const start = this.getCurrentTime_();
const range = ranges.find((r) => r.start <= start && r.end >= start);
if (!range) {
@@ -878,13 +1109,13 @@ shaka.util.CmcdManager = class {
* @private
*/
getRemainingBufferLength_(type) {
const ranges = this.playerInterface_.getBufferedInfo()[type];
const ranges = this.player_.getBufferedInfo()[type];
if (!ranges.length) {
return 0;
}
const start = this.playerInterface_.getCurrentTime();
const start = this.getCurrentTime_();
const range = ranges.find((r) => r.start <= start && r.end >= start);
if (!range) {
@@ -932,12 +1163,10 @@ shaka.util.CmcdManager = class {
* Calculate measured start delay
*
* @return {number|undefined}
* @param {!string} mode CMCD Mode [Response, Request]
* @private
*/
calculateMSD_(mode) {
if (!this.msdSent_[mode] &&
this.playbackPlayingTime_ &&
calculateMSD_() {
if (this.playbackPlayingTime_ &&
this.playbackPlayTime_) {
const startTime = this.startTimeOfLoad_ || this.playbackPlayTime_;
return this.playbackPlayingTime_ - startTime;
@@ -955,7 +1184,7 @@ shaka.util.CmcdManager = class {
* @private
*/
calculateRtp_(stream, segment) {
const playbackRate = this.playerInterface_.getPlaybackRate() || 1;
const playbackRate = this.player_.getPlaybackRate() || 1;
const currentBufferLevel =
this.getRemainingBufferLength_(stream.type) || 500;
const bandwidth = stream.bandwidth;
@@ -1012,7 +1241,7 @@ shaka.util.CmcdManager = class {
* @private
*/
getStreamType_() {
const isLive = this.playerInterface_.isLive();
const isLive = this.player_.isLive();
if (isLive) {
return shaka.util.CmcdManager.StreamType.LIVE;
} else {
@@ -1028,7 +1257,7 @@ shaka.util.CmcdManager = class {
* @private
*/
getTopBandwidth_(type) {
const variants = this.playerInterface_.getVariantTracks();
const variants = this.player_.getVariantTracks();
if (!variants.length) {
return NaN;
}
@@ -1087,7 +1316,7 @@ shaka.util.CmcdManager = class {
const stream = context.stream;
if (stream) {
const playbackRate = this.playerInterface_.getPlaybackRate();
const playbackRate = this.player_.getPlaybackRate();
if (isMedia) {
data.bl = this.getBufferLength_(stream.type);
if (data.ot !== ObjectType.TIMED_TEXT) {
@@ -1142,6 +1371,15 @@ shaka.util.CmcdManager = class {
return data;
}
/**
* Get player time.
*
* @private
* @return {number}
*/
getCurrentTime_() {
return this.video_ ? this.video_.currentTime : 0;
}
/**
* Get generic CMCD data.
@@ -1155,7 +1393,7 @@ shaka.util.CmcdManager = class {
// Apply baseline data
Object.assign(data, this.createData_());
data.pr = this.playerInterface_.getPlaybackRate();
data.pr = this.player_.getPlaybackRate();
const isVideo = data.ot === shaka.util.CmcdManager.ObjectType.VIDEO ||
data.ot === shaka.util.CmcdManager.ObjectType.MUXED;
@@ -1170,15 +1408,16 @@ shaka.util.CmcdManager = class {
data.su = this.buffering_;
}
if (this.playerInterface_.isLive()) {
data.ltc = this.playerInterface_.getLiveLatency();
if (this.player_.isLive()) {
const liveLatency = this.player_.getLiveLatency();
data.ltc = liveLatency || undefined;
}
if (document.hidden) {
data.bg = true;
}
const msd = this.calculateMSD_(mode);
const msd = this.calculateMSD_();
if (msd != undefined && !this.msdSent_[mode]) {
data.msd = msd;
this.msdSent_[mode] = true;
@@ -1333,40 +1572,6 @@ shaka.util.CmcdManager = class {
};
/**
* @typedef {{
* getBandwidthEstimate: function():number,
* getBufferedInfo: function():shaka.extern.BufferedInfo,
* getCurrentTime: function():number,
* getPlaybackRate: function():number,
* getVariantTracks: function():Array<shaka.extern.Track>,
* isLive: function():boolean,
* getLiveLatency: function():number,
* getNetworkingEngine: function():shaka.net.NetworkingEngine,
* }}
*
* @property {function():number} getBandwidthEstimate
* Get the estimated bandwidth in bits per second.
* @property {function():shaka.extern.BufferedInfo} getBufferedInfo
* Get information about what the player has buffered.
* @property {function():number} getCurrentTime
* Get the current time
* @property {function():number} getPlaybackRate
* Get the playback rate
* @property {function():Array<shaka.extern.Track>} getVariantTracks
* Get the variant tracks
* @property {function():boolean} isLive
* Get if the player is playing live content.
* @property {function():number} getLiveLatency
* Get latency in milliseconds between the live edge and what's currently
* playing.
* @property {function():shaka.net.NetworkingEngine} getNetworkingEngine
* Gets a reference to the Player's networking engine.
* Used to make requests through Shaka's networking plugins.
*/
shaka.util.CmcdManager.PlayerInterface;
/**
* @enum {string}
*/
@@ -1444,12 +1649,57 @@ shaka.util.CmcdManager.CmcdKeys = {
'rc', 'su', 'ttfb', 'ttfbb',
'ttlb', 'url', 'cmsdd', 'cmsds',
],
V2EventKeys: [
'e', 'sta',
],
CmcdV2Events: [
'ps', // Play State: Change in Play State
'e', // Error: An error event
't', // Time Interval: A periodic report sent on a time interval.
'c', // Content Id: Change of the Content Id
'b', // Backgrounded mode: Change in the application's backgrounded state
'm', // Mute: Player muted
'um', // Unmute: Player unmuted
'pe', // Player Expand: Player view was expanded
'pc', // Player Collapse: Player view was collapsed
],
CmcdV2PlayStates: [
's', // Start
'p', // Playing
'a', // Paused
'w', // Waiting
'k', // Seeking
'r', // Rebuffering
'f', // Fatal Error
'e', // Ended
'q', // Quit
'd', // Preloading
],
};
/**
* @enum {number}
*/
shaka.util.CmcdManager.CmcdV2Constants = {
TIME_INTERVAL_DEFAULT_VALUE: 10,
};
/**
* @enum {string}
*/
shaka.util.CmcdManager.CmcdV2Keys = {
TIMESTAMP: 'ts',
TIME_INTERVAL_EVENT: 't',
};
/**
* @enum {string}
*/
shaka.util.CmcdManager.CmcdMode = {
REQUEST: 'request',
RESPONSE: 'response',
EVENT: 'event',
};
File diff suppressed because it is too large Load Diff