From c33e19e2f7ccffe20fbb3c2ee929cdadbf7bd1df Mon Sep 17 00:00:00 2001 From: Juan Manuel Gonzalez <49200098+juanmanuel-qualabs@users.noreply.github.com> Date: Mon, 25 Aug 2025 04:37:09 -0300 Subject: [PATCH] feat(CMCD): Add event mode support (#8980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- externs/shaka/player.js | 13 +- lib/cast/cast_utils.js | 3 +- lib/player.js | 25 +- lib/util/cmcd_manager.js | 378 +++++++-- test/util/cmcd_manager_unit.js | 1408 +++++++++++++++++++++++++++++--- 5 files changed, 1649 insertions(+), 178 deletions(-) diff --git a/externs/shaka/player.js b/externs/shaka/player.js index 7e3776577..7bc9de989 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -2499,7 +2499,9 @@ shaka.extern.AdvancedAbrConfiguration; * enabled: boolean, * useHeaders: boolean, * url: string, - * includeKeys: !Array + * includeKeys: !Array, + * events: !Array, + * timeInterval: number, * }} * * @description @@ -2530,6 +2532,15 @@ shaka.extern.AdvancedAbrConfiguration; * If not provided, all keys will be included. *
* Defaults to []. + * @property {!Array} 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. + *
+ * Defaults to []. + * @property {number} timeInterval + * Time Interval config in seconds + *
+ * Defaults to 10. * @exportDoc */ shaka.extern.CmcdTarget; diff --git a/lib/cast/cast_utils.js b/lib/cast/cast_utils.js index abb2d65f4..dab14732f 100644 --- a/lib/cast/cast_utils.js +++ b/lib/cast/cast_utils.js @@ -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); /** diff --git a/lib/player.js b/lib/player.js index b69f0cd8f..2512dd1c5 100644 --- a/lib/player.js +++ b/lib/player.js @@ -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. diff --git a/lib/util/cmcd_manager.js b/lib/util/cmcd_manager.js index 1e63aec8a..0fa6bc327 100644 --- a/lib/util/cmcd_manager.js +++ b/lib/util/cmcd_manager.js @@ -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} */ 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} */ + 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} + * @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} 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, - * 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} 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', }; diff --git a/test/util/cmcd_manager_unit.js b/test/util/cmcd_manager_unit.js index 7d466b6d1..95950fc8d 100644 --- a/test/util/cmcd_manager_unit.js +++ b/test/util/cmcd_manager_unit.js @@ -6,6 +6,19 @@ // region CMCD Manager Setup describe('CmcdManager Setup', () => { + /** + * @extends {shaka.util.FakeEventTarget} + */ + class MockCmcdVideo extends shaka.util.FakeEventTarget { + constructor() { + super(); + /** @type {number} */ + this.currentTime = 0; + /** @type {boolean} */ + this.muted = false; + } + } + const createSegmentContextWithIndex = (segmentIndex) => { const baseContext = createSegmentContext(); return Object.assign({}, baseContext, { @@ -122,7 +135,8 @@ describe('CmcdManager Setup', () => { 'com.test-token': Symbol('s'), }; - const playerInterface = { + const mockPlayer = new shaka.util.FakeEventTarget(); + Object.assign(mockPlayer, { isLive: () => false, getLiveLatency: () => 0, getBandwidthEstimate: () => 10000000, @@ -134,9 +148,8 @@ describe('CmcdManager Setup', () => { ], }), getNetworkingEngine: () => createNetworkingEngine( - createCmcdManager(playerInterface, createCmcdConfig()), + createCmcdManager(mockPlayer, createCmcdConfig()), ), - getCurrentTime: () => 10, getPlaybackRate: () => 1, getVariantTracks: () => /** @type {Array} */ ([ { @@ -152,7 +165,7 @@ describe('CmcdManager Setup', () => { audioBandWidth: 1000000, }, ]), - }; + }); const config = { enabled: true, @@ -169,7 +182,14 @@ describe('CmcdManager Setup', () => { } function createCmcdManager(player, cfg = {}) { - return new CmcdManager(player, createCmcdConfig(cfg)); + const cmcdManager = new CmcdManager(player, createCmcdConfig(cfg)); + // Mock video element for time calculations + const video = new MockCmcdVideo(); + video.currentTime = 10; + cmcdManager.setMediaElement( + /** @type {!HTMLMediaElement} */ (/** @type {*} */ (video))); + + return cmcdManager; } function createRequest() { @@ -244,7 +264,7 @@ describe('CmcdManager Setup', () => { const ObjectUtils = shaka.util.ObjectUtils; /** @type shaka.util.CmcdManager */ - let cmcdManager = createCmcdManager(playerInterface); + let cmcdManager = createCmcdManager(mockPlayer); const createContext = (type) => { return { @@ -269,7 +289,7 @@ describe('CmcdManager Setup', () => { describe('configuration', () => { it('does not modify requests when disabled', () => { cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { enabled: false, }, @@ -285,7 +305,7 @@ describe('CmcdManager Setup', () => { it('generates a session id if not provided', () => { cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { sessionId: '', }, @@ -299,7 +319,7 @@ describe('CmcdManager Setup', () => { }); it('generates a session id via configure', () => { - cmcdManager = createCmcdManager(playerInterface); + cmcdManager = createCmcdManager(mockPlayer); const r = createRequest(); cmcdManager.applyManifestData(r, manifestInfo); @@ -324,7 +344,7 @@ describe('CmcdManager Setup', () => { it('filters keys if includeKeys is provided', () => { cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { includeKeys: ['sid', 'cid'], }, @@ -341,7 +361,7 @@ describe('CmcdManager Setup', () => { describe('query mode', () => { it('modifies all request uris', () => { // modifies manifest request uris - cmcdManager = createCmcdManager(playerInterface); + cmcdManager = createCmcdManager(mockPlayer); let r = createRequest(); cmcdManager.applyManifestData(r, manifestInfo); @@ -369,7 +389,7 @@ describe('CmcdManager Setup', () => { describe('header mode', () => { it('modifies all request headers', () => { cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { useHeaders: true, }, @@ -412,7 +432,7 @@ describe('CmcdManager Setup', () => { describe('src= mode', () => { beforeEach(() => { - cmcdManager = createCmcdManager(playerInterface); + cmcdManager = createCmcdManager(mockPlayer); }); it('modifies media stream uris', () => { @@ -445,7 +465,7 @@ describe('CmcdManager Setup', () => { describe('adheres to the spec', () => { beforeEach(() => { cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { useHeaders: true, }, @@ -486,7 +506,7 @@ describe('CmcdManager Setup', () => { const retry = NetworkingEngine.defaultRetryParameters(); beforeEach(() => { - cmcdManager = createCmcdManager(playerInterface); + cmcdManager = createCmcdManager(mockPlayer); networkingEngine = createNetworkingEngine(cmcdManager); }); @@ -588,7 +608,7 @@ describe('CmcdManager Setup', () => { it('not when enabled is false', async () => { cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { enabled: false, }, @@ -603,10 +623,13 @@ describe('CmcdManager Setup', () => { }); it('returns cmcd v2 data in query if version is 2', async () => { - // Set live to true to enable ltc - playerInterface.isLive = () => true; + const livePlayer = new shaka.util.FakeEventTarget(); + Object.assign(livePlayer, mockPlayer, { + isLive: () => true, + getLiveLatency: () => 3100, + }); cmcdManager = createCmcdManager( - playerInterface, + livePlayer, { version: 2, includeKeys: ['ltc', 'msd', 'v'], @@ -628,11 +651,14 @@ describe('CmcdManager Setup', () => { it('doesn\'t return cmcd v2 data in query if version is not 2', async () => { - // Set live to true to enable ltc - playerInterface.isLive = () => true; + const livePlayer = new shaka.util.FakeEventTarget(); + Object.assign(livePlayer, mockPlayer, { + isLive: () => true, + getLiveLatency: () => 3100, + }); const cmcdManagerTmp = createCmcdManager( - playerInterface, + livePlayer, { version: 1, includeKeys: ['ltc', 'msd'], @@ -653,9 +679,13 @@ describe('CmcdManager Setup', () => { }); it('returns cmcd v2 data in header if version is 2', async () => { - playerInterface.isLive = () => true; + const livePlayer = new shaka.util.FakeEventTarget(); + Object.assign(livePlayer, mockPlayer, { + isLive: () => true, + getLiveLatency: () => 3100, + }); cmcdManager = createCmcdManager( - playerInterface, + livePlayer, { version: 2, includeKeys: ['ltc', 'msd'], @@ -676,9 +706,13 @@ describe('CmcdManager Setup', () => { it('doesn\'t return cmcd v2 data in headers if version is not 2', async () => { - playerInterface.isLive = () => true; + const livePlayer = new shaka.util.FakeEventTarget(); + Object.assign(livePlayer, mockPlayer, { + isLive: () => true, + getLiveLatency: () => 3100, + }); cmcdManager = createCmcdManager( - playerInterface, + livePlayer, { version: 1, includeKeys: ['ltc', 'msd'], @@ -698,7 +732,7 @@ describe('CmcdManager Setup', () => { it('generates `nrr` for CMCD V1 segment requests', () => { cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { version: 1, includeKeys: ['ltc', 'msd', 'nrr'], @@ -719,7 +753,7 @@ describe('CmcdManager Setup', () => { it('generates `nor` for URL-based segment requests', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, ); const request = createRequest(); @@ -735,7 +769,7 @@ describe('CmcdManager Setup', () => { it('generates `nrr` for byte-range segment requests', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, ); const request = createRequest(); // Create a context where the next segment HAS a byte range @@ -785,12 +819,13 @@ describe('CmcdManager Setup', () => { 'com.test-token': Symbol('s'), }; - const playerInterface = { + const mockPlayer = new shaka.util.FakeEventTarget(); + Object.assign(mockPlayer, { isLive: () => true, getLiveLatency: () => 3100, getBandwidthEstimate: () => 10000000, getNetworkingEngine: () => createNetworkingEngine( - createCmcdManager(playerInterface, createCmcdConfig()), + createCmcdManager(mockPlayer, createCmcdConfig()), ), getBufferedInfo: () => ({ video: [ @@ -799,7 +834,6 @@ describe('CmcdManager Setup', () => { {start: 35, end: 40}, ], }), - getCurrentTime: () => 5, getPlaybackRate: () => 1.25, getVariantTracks: () => [ { @@ -815,7 +849,7 @@ describe('CmcdManager Setup', () => { audioBandwidth: 1000000, }, ], - }; + }); const baseConfig = { enabled: true, @@ -834,9 +868,16 @@ describe('CmcdManager Setup', () => { }; const createCmcdConfig = (cfg = {}) => Object.assign({}, baseConfig, cfg); - const createCmcdManager = (player, cfg = {}) => new CmcdManager( - player, createCmcdConfig(cfg), - ); + const createCmcdManager = (player, cfg = {}) => { + const cmcdManager = new CmcdManager(player, createCmcdConfig(cfg)); + // Mock video element for time calculations + const video = new MockCmcdVideo(); + video.currentTime = 5; + cmcdManager.setMediaElement( + /** @type {!HTMLMediaElement} */ (/** @type {*} */ (video))); + + return cmcdManager; + }; const createRequest = () => ({ uris: ['https://test.com/v2test.mpd'], @@ -881,7 +922,7 @@ describe('CmcdManager Setup', () => { describe('Configuration and Mode Handling', () => { it('filters CMCD response mode keys correctly', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [{ mode: 'response', @@ -916,7 +957,7 @@ describe('CmcdManager Setup', () => { }); it('applies CMCD data to request URL in query mode', () => { - const cmcdManager = createCmcdManager(playerInterface); + const cmcdManager = createCmcdManager(mockPlayer); const request = createRequest(); cmcdManager.applyManifestData(request, {}); @@ -927,7 +968,7 @@ describe('CmcdManager Setup', () => { it('applies CMCD data to request headers in header mode', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, {useHeaders: true}, ); @@ -939,7 +980,7 @@ describe('CmcdManager Setup', () => { it('applies CMCD data to response URL in query mode', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { version: 2, targets: [{ @@ -971,7 +1012,7 @@ describe('CmcdManager Setup', () => { it('includes response code in response mode (query)', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { version: 2, targets: [{ @@ -999,7 +1040,7 @@ describe('CmcdManager Setup', () => { it('includes response code in response headers', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { version: 2, targets: [{ @@ -1024,7 +1065,7 @@ describe('CmcdManager Setup', () => { it('does not include response code if not provided', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { version: 2, targets: [{ @@ -1056,7 +1097,7 @@ describe('CmcdManager Setup', () => { it('applies CMCD data to response headers in header mode', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { version: 2, targets: [{ @@ -1086,7 +1127,7 @@ describe('CmcdManager Setup', () => { it('applies v2 keys to response uri in response mode', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [{ mode: 'response', @@ -1123,7 +1164,7 @@ describe('CmcdManager Setup', () => { it('filters keys in response mode based on includeKeys', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['sid', 'msd'], @@ -1149,7 +1190,7 @@ describe('CmcdManager Setup', () => { it('filters keys in request mode based on includeKeys', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['sid', 'msd'], @@ -1176,7 +1217,7 @@ describe('CmcdManager Setup', () => { describe('CMCD v2 Key Generation', () => { it('sn increments sequence number for each request', () => { - const cmcdManager = createCmcdManager(playerInterface, { + const cmcdManager = createCmcdManager(mockPlayer, { includeKeys: ['sn'], }); const request1 = createRequest(); @@ -1190,7 +1231,7 @@ describe('CmcdManager Setup', () => { it('sn increments sequence number for each response', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['sn'], @@ -1217,7 +1258,7 @@ describe('CmcdManager Setup', () => { it('sn increments sequence numbers across multiple targets', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [ { @@ -1281,7 +1322,7 @@ describe('CmcdManager Setup', () => { it('sn ignores disabled response targets', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [ { @@ -1315,7 +1356,7 @@ describe('CmcdManager Setup', () => { it('includes ltc for live content request mode', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['sid', 'msd'], @@ -1330,7 +1371,7 @@ describe('CmcdManager Setup', () => { it('includes ltc for live content response mode', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['sid', 'msd', 'ltc'], @@ -1350,7 +1391,7 @@ describe('CmcdManager Setup', () => { it('sends `msd` only on the first request', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['sid', 'msd'], @@ -1375,7 +1416,7 @@ describe('CmcdManager Setup', () => { it('sends `msd` only on the first response', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['sid', 'msd'], @@ -1409,7 +1450,7 @@ describe('CmcdManager Setup', () => { }); it('should generate "sf" for manifest requests', () => { - const cmcdManager = createCmcdManager(playerInterface); + const cmcdManager = createCmcdManager(mockPlayer); const r = createRequest(); const context = { @@ -1424,7 +1465,7 @@ describe('CmcdManager Setup', () => { it('should generate "sf" for segment responses', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['sf'], @@ -1450,7 +1491,7 @@ describe('CmcdManager Setup', () => { }); it('should generate "bs" after a rebuffering event request', () => { - const cmcdManager = createCmcdManager(playerInterface); + const cmcdManager = createCmcdManager(mockPlayer); const context = createSegmentContextWithIndex(createMockSegmentIndex()); cmcdManager.setBuffering(false); @@ -1471,7 +1512,7 @@ describe('CmcdManager Setup', () => { it('should generate "bs" after a rebuffering event response mode', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['bs', 'ot'], @@ -1507,7 +1548,7 @@ describe('CmcdManager Setup', () => { it('generates `rtp` for segment requests', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['sid', 'msd', 'rtp'], @@ -1524,7 +1565,7 @@ describe('CmcdManager Setup', () => { it('request excludes `nrr` key for v2, even if requested', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { includeKeys: ['nrr', 'rtp'], }, @@ -1540,7 +1581,7 @@ describe('CmcdManager Setup', () => { }); it('generates `nor` for URL-based segment requests', () => { - const cmcdManager = createCmcdManager(playerInterface); + const cmcdManager = createCmcdManager(mockPlayer); const request = createRequest(); const context = createSegmentContextWithIndex(createMockNextSegment(false)); @@ -1554,7 +1595,7 @@ describe('CmcdManager Setup', () => { it('generates `rtp` for segment responses', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['sid', 'msd', 'rtp', 'nor'], @@ -1579,7 +1620,7 @@ describe('CmcdManager Setup', () => { it('sends ttfb and ttlb query', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { version: 2, targets: [{ @@ -1609,7 +1650,7 @@ describe('CmcdManager Setup', () => { it('sends ttfb and ttlb in headers', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { version: 2, targets: [{ @@ -1635,7 +1676,7 @@ describe('CmcdManager Setup', () => { it('does not generate ttfb or ttlb if timing info is missing', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { version: 2, targets: [{ @@ -1663,7 +1704,7 @@ describe('CmcdManager Setup', () => { }); it('generates `nor` for URL-based segment responses', () => { - const cmcdManager = createCmcdManager(playerInterface); + const cmcdManager = createCmcdManager(mockPlayer); const response = createResponse(); const context = createSegmentContextWithIndex(createMockNextSegment(false)); @@ -1677,7 +1718,7 @@ describe('CmcdManager Setup', () => { it('includes the request URL, without CMCD in response mode', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { version: 2, targets: [{ @@ -1710,7 +1751,7 @@ describe('CmcdManager Setup', () => { }); it('cmcd url key preserves other query parameters', () => { - const cmcdManager = createCmcdManager(playerInterface, { + const cmcdManager = createCmcdManager(mockPlayer, { version: 2, targets: [{ mode: 'response', @@ -1740,7 +1781,7 @@ describe('CmcdManager Setup', () => { }); it('cmcd url key does not modify URL if no CMCD param is present', () => { - const cmcdManager = createCmcdManager(playerInterface, { + const cmcdManager = createCmcdManager(mockPlayer, { version: 2, targets: [{ mode: 'response', @@ -1768,7 +1809,7 @@ describe('CmcdManager Setup', () => { }); it('cmcd url key preserves URL fragments (hash)', () => { - const cmcdManager = createCmcdManager(playerInterface, { + const cmcdManager = createCmcdManager(mockPlayer, { version: 2, targets: [{ mode: 'response', @@ -1797,7 +1838,7 @@ describe('CmcdManager Setup', () => { }); it('cmcd url key handles an empty CMCD parameter', () => { - const cmcdManager = createCmcdManager(playerInterface, { + const cmcdManager = createCmcdManager(mockPlayer, { version: 2, targets: [{ mode: 'response', @@ -1826,7 +1867,7 @@ describe('CmcdManager Setup', () => { it('should generate "cmsdd" from response header', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['cmsdd'], @@ -1866,7 +1907,7 @@ describe('CmcdManager Setup', () => { it('cmsdd value should be Base64 encoded', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['cmsdd'], @@ -1905,7 +1946,7 @@ describe('CmcdManager Setup', () => { it('should send "cmsdd" in headers mode', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['cmsdd'], @@ -1952,7 +1993,7 @@ describe('CmcdManager Setup', () => { it('should not include "cmsdd" if header is not present', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['cmsdd'], @@ -1975,7 +2016,7 @@ describe('CmcdManager Setup', () => { it('response excludes `nrr` key for v2, even if requested', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['sid', 'msd', 'rtp', 'nor', 'nrr'], @@ -2004,7 +2045,7 @@ describe('CmcdManager Setup', () => { Object.defineProperty(document, 'hidden', {value: true, configurable: true}); - const cmcdManager = createCmcdManager(playerInterface, + const cmcdManager = createCmcdManager(mockPlayer, {useHeaders: false}); const request = createRequest(); @@ -2018,7 +2059,7 @@ describe('CmcdManager Setup', () => { Object.defineProperty(document, 'hidden', {value: true, configurable: true}); - const cmcdManager = createCmcdManager(playerInterface, + const cmcdManager = createCmcdManager(mockPlayer, {useHeaders: true}); const request = createRequest(); @@ -2030,7 +2071,7 @@ describe('CmcdManager Setup', () => { Object.defineProperty(document, 'hidden', {value: false, configurable: true}); - const cmcdManager = createCmcdManager(playerInterface, + const cmcdManager = createCmcdManager(mockPlayer, {useHeaders: true}); @@ -2046,7 +2087,7 @@ describe('CmcdManager Setup', () => { it('includes `bg` in response mode when page is hidden', () => { Object.defineProperty(document, 'hidden', {value: true}); const cmcdManager = createCmcdManager( - playerInterface, { + mockPlayer, { targets: [{ mode: 'response', enabled: true, @@ -2067,7 +2108,7 @@ describe('CmcdManager Setup', () => { Object.defineProperty(document, 'hidden', {value: true, configurable: true}); - const cmcdManager = createCmcdManager(playerInterface, + const cmcdManager = createCmcdManager(mockPlayer, {useHeaders: true}); const request = createRequest(); @@ -2078,7 +2119,7 @@ describe('CmcdManager Setup', () => { it('request does not include v2 keys if version is not 2', () => { const nonV2Manager = createCmcdManager( - playerInterface, + mockPlayer, {version: 1, includeKeys: ['msd', 'ltc']}, ); const request = createRequest(); @@ -2089,7 +2130,7 @@ describe('CmcdManager Setup', () => { }); it('includes ts for segment requests', () => { - const cmcdManager = createCmcdManager(playerInterface, {version: 2}); + const cmcdManager = createCmcdManager(mockPlayer, {version: 2}); const request = createRequest(); const context = createSegmentContext(); cmcdManager.applyRequestSegmentData(request, context); @@ -2098,7 +2139,7 @@ describe('CmcdManager Setup', () => { }); it('includes ts for segment responses', () => { - const cmcdManager = createCmcdManager(playerInterface, { + const cmcdManager = createCmcdManager(mockPlayer, { version: 2, targets: [{ mode: 'response', @@ -2127,11 +2168,12 @@ describe('CmcdManager Setup', () => { {uri: '', data: new ArrayBuffer(5), headers: {}}), ); - const playerInterfaceWithSpy = Object.assign({}, playerInterface, { + const playerWithSpy = new shaka.util.FakeEventTarget(); + Object.assign(playerWithSpy, mockPlayer, { getNetworkingEngine: () => networkingEngine, }); - const cmcdManager = createCmcdManager(playerInterfaceWithSpy, { + const cmcdManager = createCmcdManager(playerWithSpy, { version: 2, targets: [{ mode: 'response', @@ -2155,7 +2197,7 @@ describe('CmcdManager Setup', () => { }); it('reuses request timestamp for response mode', () => { - const cmcdManager = createCmcdManager(playerInterface, { + const cmcdManager = createCmcdManager(mockPlayer, { version: 2, targets: [{ mode: 'response', @@ -2197,12 +2239,13 @@ describe('CmcdManager Setup', () => { {uri: '', data: new ArrayBuffer(5), headers: {}}), ); - const playerInterfaceWithSpy = Object.assign({}, playerInterface, { + const playerWithSpy = new shaka.util.FakeEventTarget(); + Object.assign(playerWithSpy, mockPlayer, { getNetworkingEngine: () => networkingEngine, }); const cmcdManager = createCmcdManager( - playerInterfaceWithSpy, + playerWithSpy, { version: 2, targets: [{ @@ -2272,11 +2315,12 @@ describe('CmcdManager Setup', () => { () => shaka.util.AbortableOperation.completed( {uri: '', data: new ArrayBuffer(5), headers: {}})); - const playerInterfaceWithSpy = Object.assign({}, playerInterface, { + const playerWithSpy = new shaka.util.FakeEventTarget(); + Object.assign(playerWithSpy, mockPlayer, { getNetworkingEngine: () => networkingEngine, }); - const cmcdManager = createCmcdManager(playerInterfaceWithSpy, { + const cmcdManager = createCmcdManager(playerWithSpy, { enabled: false, version: 2, targets: [{ @@ -2314,7 +2358,7 @@ describe('CmcdManager Setup', () => { it('response does not include v2 keys if version is not 2', () => { const nonV2Manager = createCmcdManager( - playerInterface, + mockPlayer, {version: 1, includeKeys: ['msd', 'ltc']}, ); const response = createResponse(); @@ -2327,7 +2371,7 @@ describe('CmcdManager Setup', () => { it('cmcd does not include the url parameter for CMCD v1', () => { const cmcdManager = createCmcdManager( - playerInterface, { + mockPlayer, { // Explicitly set version to 1 version: 1, targets: [{ @@ -2357,7 +2401,7 @@ describe('CmcdManager Setup', () => { it('should generate "cmsds" from response header', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['cmsds'], @@ -2388,7 +2432,7 @@ describe('CmcdManager Setup', () => { it('cmsds value should be Base64 encoded', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['cmsds'], @@ -2418,7 +2462,7 @@ describe('CmcdManager Setup', () => { it('should send "cmsds" in headers mode', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['cmsds'], @@ -2455,7 +2499,7 @@ describe('CmcdManager Setup', () => { it('should not include "cmsds" if header is not present', () => { const cmcdManager = createCmcdManager( - playerInterface, + mockPlayer, { targets: [Object.assign({}, baseConfig.targets[0], { includeKeys: ['cmsds'], @@ -2475,5 +2519,1173 @@ describe('CmcdManager Setup', () => { const sentCmcdData = spy.calls.argsFor(0)[0]; expect(sentCmcdData.cmsds).toBeUndefined(); }); + + // region CMCD V2 Event mode + describe('Event Mode', () => { + /** + * A mock media element that extends FakeEventTarget and adds properties + * for testing CMCD event mode. + * @extends {shaka.util.FakeEventTarget} + */ + class MockMediaElement extends shaka.util.FakeEventTarget { + constructor() { + super(); + /** @type {boolean} */ + this.muted = false; + } + } + + let networkingEngine; + let requestSpy; + /** @type {shaka.util.FakeEventTarget} */ + let mockPlayerWithNE; + + beforeEach(() => { + requestSpy = jasmine.createSpy('request'); + networkingEngine = { + request: requestSpy, + configure: () => {}, + registerScheme: () => {}, + }; + mockPlayerWithNE = new shaka.util.FakeEventTarget(); + Object.assign(mockPlayerWithNE, mockPlayer, { + getNetworkingEngine: () => networkingEngine, + }); + }); + + it('sends player state change events', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e', 'sta', 'v'], + events: ['ps'], + }], + }; + + const cmcdManager = createCmcdManager( + mockPlayerWithNE, + config, + ); + cmcdManager.setMediaElement(mockVideo); + cmcdManager.configure(config); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + let request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + let decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="s"'); + expect(decodedUri).toContain('v=2'); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('playing')); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="p"'); + expect(decodedUri).toContain('v=2'); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('pause')); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="a"'); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('seeking')); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="k"'); + }); + + it('sends mute and unmute events', () => { + const mockMediaElement = /** @type {!MockMediaElement} */ ( + new MockMediaElement()); + + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e', 'sta', 'v'], + events: ['m', 'um'], + }], + }; + + const cmcdManager = createCmcdManager(mockPlayerWithNE, config); + cmcdManager.setMediaElement(mockMediaElement); + + // Mute + mockMediaElement.muted = true; + mockMediaElement.dispatchEvent( + new shaka.util.FakeEvent('volumechange'), + ); + + let request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + let decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="m"'); + expect(decodedUri).toContain('v=2'); + + // Unmute + mockMediaElement.muted = false; + mockMediaElement.dispatchEvent( + new shaka.util.FakeEvent('volumechange'), + ); + + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="um"'); + }); + + describe('Time interval events', () => { + beforeEach(() => jasmine.clock().install()); + afterEach(() => jasmine.clock().uninstall()); + + it('sends time interval events', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + timeInterval: 1, + includeKeys: ['e', 'v'], + }], + }; + + const cmcdManager = createCmcdManager( + mockPlayerWithNE, + config, + ); + cmcdManager.setMediaElement(mockVideo); + cmcdManager.configure(config); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + + jasmine.clock().tick(1001); + const request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + const decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="t"'); + expect(decodedUri).toContain('v=2'); + }); + + it('does not send time interval events when timeInterval is 0', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + timeInterval: 0, + }], + }; + + const cmcdManager = createCmcdManager( + mockPlayerWithNE, + config, + ); + cmcdManager.setMediaElement(mockVideo); + cmcdManager.configure(config); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + expect(requestSpy).toHaveBeenCalledTimes(1); + + jasmine.clock().tick(20000); + + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + it('uses default time interval when not specified', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + // timeInterval is not defined, should default to 10s. + includeKeys: ['e', 'v'], + }], + }; + + const cmcdManager = createCmcdManager( + mockPlayerWithNE, + config, + ); + cmcdManager.setMediaElement(mockVideo); + cmcdManager.configure(config); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + expect(requestSpy).not.toHaveBeenCalled(); + + // Default time interval is 10 seconds. + jasmine.clock().tick(10001); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + const decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="t"'); + expect(decodedUri).toContain('v=2'); + }); + }); + + it('sends `msd` only on the first event', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const cmcdManager = createCmcdManager( + mockPlayerWithNE, + { + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['msd', 'e', 'sta'], + }], + }, + ); + cmcdManager.setMediaElement(mockVideo); + cmcdManager.onPlaybackPlay_(); + cmcdManager.onPlaybackPlaying_(); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + let request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + + expect(requestSpy).toHaveBeenCalled(); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('playing')); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + expect(requestSpy).toHaveBeenCalled(); + + let decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('msd='); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('pause')); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + expect(requestSpy).toHaveBeenCalled(); + + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).not.toContain('msd='); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + expect(requestSpy).toHaveBeenCalled(); + + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).not.toContain('msd='); + }); + + it('filters events based on the target configuration', () => { + const mockMediaElement = /** @type {!MockMediaElement} */ ( + new MockMediaElement()); + + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e', 'sta'], + events: ['m'], + }], + }; + + const cmcdManager = createCmcdManager(mockPlayerWithNE, config); + cmcdManager.setMediaElement(mockMediaElement); + + mockMediaElement.dispatchEvent(new shaka.util.FakeEvent('play')); + + expect(requestSpy).not.toHaveBeenCalled(); + + mockMediaElement.dispatchEvent(new shaka.util.FakeEvent('pause')); + // Should not have been called again for 'pause' + expect(requestSpy).not.toHaveBeenCalled(); + + mockMediaElement.dispatchEvent(new shaka.util.FakeEvent('seeking')); + // Should not have been called again for 'seeking' + expect(requestSpy).not.toHaveBeenCalled(); + + // Mute + /** @type boolean */ + mockMediaElement.muted = true; + mockMediaElement.dispatchEvent( + new shaka.util.FakeEvent('volumechange'), + ); + + const request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + + const decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="m"'); + expect(decodedUri).not.toContain('e="ps"'); + expect(decodedUri).not.toContain('sta="p"'); + expect(decodedUri).not.toContain('sta="a"'); + expect(decodedUri).not.toContain('sta="k"'); + + // Should not have been called again for 'seeking' + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + it('includes other CMCD data with event requests', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + + const config = { + version: 2, + enabled: true, + sessionId: sessionId, + contentId: 'v2-event-content', + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e', 'sta', 'bl', 'mtp', 'cid'], + events: ['ps'], + }], + }; + + const cmcdManager = createCmcdManager(mockPlayerWithNE, config); + cmcdManager.setMediaElement(mockVideo); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + let request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + let decodedUri = decodeURIComponent(request.uris[0]); + + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="s"'); + expect(decodedUri).toContain('mtp='); + expect(decodedUri).toContain('cid="v2-event-content"'); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('playing')); + + expect(requestSpy).toHaveBeenCalled(); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="p"'); + expect(decodedUri).toContain('mtp='); + expect(decodedUri).toContain('cid="v2-event-content"'); + }); + + it('does not send events if the target is disabled', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: false, // Target is disabled + url: 'https://example.com/cmcd', + }], + }; + + const cmcdManager = createCmcdManager(mockPlayerWithNE, config); + cmcdManager.setMediaElement(mockVideo); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it('does not send events if CMCD version is 1', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 1, // CMCD v1 + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + }], + }; + + const cmcdManager = createCmcdManager(mockPlayerWithNE, config); + cmcdManager.setMediaElement(mockVideo); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it('filters out keys that are not valid for event mode', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + // d and rtp are not valid for event mode + includeKeys: ['e', 'sta', 'bl', 'd', 'rtp'], + events: ['ps'], + }], + }; + + const cmcdManager = createCmcdManager(mockPlayerWithNE, config); + cmcdManager.setMediaElement(mockVideo); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + + expect(requestSpy).toHaveBeenCalled(); + let request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + let decodedUri = decodeURIComponent(request.uris[0]); + + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="s"'); + expect(decodedUri).not.toContain('d='); + expect(decodedUri).not.toContain('rtp='); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('playing')); + + expect(requestSpy).toHaveBeenCalled(); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="p"'); + expect(decodedUri).not.toContain('d='); + expect(decodedUri).not.toContain('rtp='); + }); + + it('sends events to multiple targets', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + targets: [ + { + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd1', + includeKeys: ['e', 'sta'], + events: ['ps'], + }, + { + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd2', + includeKeys: ['e', 'sta', 'v'], + events: ['ps'], + }, + ], + }; + + const cmcdManager = createCmcdManager(mockPlayerWithNE, config); + cmcdManager.setMediaElement(mockVideo); + cmcdManager.configure(config); + + // Dispatch 'play' event + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + + // After 'play', two requests should have been sent + expect(requestSpy).toHaveBeenCalledTimes(2); + + const playCalls = /** @type {!jasmine.Spy} */ + (requestSpy).calls.all().map((call) => call.args[1]); + const playCall1 = playCalls.find((req) => req.uris[0].startsWith('https://example.com/cmcd1')); + const playCall2 = playCalls.find((req) => req.uris[0].startsWith('https://example.com/cmcd2')); + + // Assertions for the 'play' event + const decodedUri1 = decodeURIComponent(playCall1.uris[0]); + expect(decodedUri1).toContain('e="ps"'); + expect(decodedUri1).toContain('sta="s"'); + expect(decodedUri1).not.toContain('v=2'); + + const decodedUri2 = decodeURIComponent(playCall2.uris[0]); + expect(decodedUri2).toContain('e="ps"'); + expect(decodedUri2).toContain('sta="s"'); + expect(decodedUri2).toContain('v=2'); + + // Reset the spy before the next event to have clean calls + /** @type {!jasmine.Spy} */ (requestSpy).calls.reset(); + + // Dispatch 'playing' event + mockVideo.dispatchEvent(new shaka.util.FakeEvent('playing')); + + // After 'playing', two more requests should have been sent + expect(requestSpy).toHaveBeenCalledTimes(2); + + const playingCalls = /** @type {!jasmine.Spy} */ + (requestSpy).calls.all().map((call) => call.args[1]); + const playingCall1 = playingCalls.find((req) => req.uris[0].startsWith('https://example.com/cmcd1')); + const playingCall2 = playingCalls.find((req) => req.uris[0].startsWith('https://example.com/cmcd2')); + + // Assertions for the 'playing' event + const decodedUri3 = decodeURIComponent(playingCall1.uris[0]); + expect(decodedUri3).toContain('e="ps"'); + expect(decodedUri3).toContain('sta="p"'); + expect(decodedUri3).not.toContain('v=2'); + + const decodedUri4 = decodeURIComponent(playingCall2.uris[0]); + expect(decodedUri4).toContain('e="ps"'); + expect(decodedUri4).toContain('sta="p"'); + expect(decodedUri4).toContain('v=2'); + }); + + it('sends events using headers', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + sessionId: sessionId, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e', 'sta', 'v', 'sid'], + events: ['ps'], + useHeaders: true, + }], + }; + + const cmcdManager = createCmcdManager(mockPlayerWithNE, config); + cmcdManager.setMediaElement(mockVideo); + cmcdManager.configure(config); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + let request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + + expect(request.uris[0]).toBe('https://example.com/cmcd'); + expect(request.headers['CMCD-Request']).toContain('e="ps"'); + expect(request.headers['CMCD-Request']).toContain('sta="s"'); + expect(request.headers['CMCD-Request']).toContain('ts='); + + expect(request.headers['CMCD-Session']).toContain('v=2'); + expect(request.headers['CMCD-Session']).toContain(`sid="${sessionId}"`); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('playing')); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + + expect(request.uris[0]).toBe('https://example.com/cmcd'); + expect(request.headers['CMCD-Request']).toContain('e="ps"'); + expect(request.headers['CMCD-Request']).toContain('sta="p"'); + expect(request.headers['CMCD-Request']).toContain('ts='); + expect(request.headers['CMCD-Session']).toContain('v=2'); + expect(request.headers['CMCD-Session']).toContain(`sid="${sessionId}"`); + }); + + it('includes timestamp (ts) in event reports', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e', 'sta', 'ts'], + events: ['ps'], + }], + }; + + const cmcdManager = createCmcdManager(mockPlayerWithNE, config); + cmcdManager.setMediaElement(mockVideo); + cmcdManager.configure(config); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + const request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + const decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="s"'); + expect(decodedUri).toContain('ts='); + }); + + it('should return only enabled event targets', () => { + const targets = [ + {mode: 'event', enabled: true, url: 'url1'}, + {mode: 'event', enabled: false, url: 'url2'}, + {mode: 'request', enabled: true, url: 'url3'}, + {mode: 'event', enabled: true, url: 'url4'}, + ]; + const cmcdManager = createCmcdManager(mockPlayer, {targets}); + const enabledEventTargets = cmcdManager.getEventModeEnabledTargets_(); + expect(enabledEventTargets.length).toBe(2); + expect(enabledEventTargets[0].url).toBe('url1'); + expect(enabledEventTargets[1].url).toBe('url4'); + }); + + it('should return an empty array if no targets are configured', () => { + const cmcdManager = createCmcdManager(mockPlayer, {targets: []}); + const enabledEventTargets = cmcdManager.getEventModeEnabledTargets_(); + expect(enabledEventTargets.length).toBe(0); + }); + + it('should return an empty array if no event targets are enabled', () => { + const targets = [ + {mode: 'event', enabled: false, url: 'url1'}, + {mode: 'request', enabled: true, url: 'url2'}, + ]; + const cmcdManager = createCmcdManager(mockPlayer, {targets}); + const enabledEventTargets = cmcdManager.getEventModeEnabledTargets_(); + expect(enabledEventTargets.length).toBe(0); + }); + + it('should return only enabled event targets', () => { + const targets = [ + {mode: 'event', enabled: true, url: 'url1'}, + {mode: 'event', enabled: false, url: 'url2'}, + {mode: 'request', enabled: true, url: 'url3'}, + {mode: 'event', enabled: true, url: 'url4'}, + ]; + const cmcdManager = createCmcdManager(mockPlayer, {targets}); + const enabledEventTargets = cmcdManager.getEventModeEnabledTargets_(); + expect(enabledEventTargets.length).toBe(2); + expect(enabledEventTargets[0].url).toBe('url1'); + expect(enabledEventTargets[1].url).toBe('url4'); + }); + + it('should return an empty array if no targets are configured', () => { + const cmcdManager = createCmcdManager(mockPlayer, {targets: []}); + const enabledEventTargets = cmcdManager.getEventModeEnabledTargets_(); + expect(enabledEventTargets.length).toBe(0); + }); + + it('should return an empty array if no event targets are enabled', () => { + const targets = [ + {mode: 'event', enabled: false, url: 'url1'}, + {mode: 'request', enabled: true, url: 'url2'}, + ]; + const cmcdManager = createCmcdManager(mockPlayer, {targets}); + const enabledEventTargets = cmcdManager.getEventModeEnabledTargets_(); + expect(enabledEventTargets.length).toBe(0); + }); + + it('does not include rc, url, ttfb or ttlb key', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + // rc, url, ttfb and ttlb are not valid for event mode + includeKeys: ['e', 'sta', 'rc', 'url', 'ttfb', 'ttlb'], + events: ['ps'], + }], + }; + + const cmcdManager = createCmcdManager(mockPlayerWithNE, config); + cmcdManager.setMediaElement(mockVideo); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + + expect(requestSpy).toHaveBeenCalled(); + const request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + const decodedUri = decodeURIComponent(request.uris[0]); + + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="s"'); + expect(decodedUri).not.toContain('rc='); + expect(decodedUri).not.toContain('url='); + expect(decodedUri).not.toContain('ttfb='); + expect(decodedUri).not.toContain('ttlb='); + }); + + it('always includes timestamp (ts) in event reports', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e', 'sta'], // ts is omitted + events: ['ps'], + }], + }; + + const cmcdManager = createCmcdManager(mockPlayerWithNE, config); + cmcdManager.setMediaElement(mockVideo); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + + expect(requestSpy).toHaveBeenCalled(); + const request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + const decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toMatch(/ts=\d+/); + }); + + it('sends all allowed keys when includeKeys is empty', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + sessionId: sessionId, + contentId: 'v2-event-content', + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: [], + events: ['ps'], + }], + }; + + const cmcdManager = createCmcdManager(mockPlayerWithNE, config); + cmcdManager.setMediaElement(mockVideo); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('play')); + + expect(requestSpy).toHaveBeenCalled(); + let request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + let decodedUri = decodeURIComponent(request.uris[0]); + + // Check for essential event keys + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="s"'); + + mockVideo.dispatchEvent(new shaka.util.FakeEvent('playing')); + + expect(requestSpy).toHaveBeenCalled(); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + + // Check for essential event keys + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="p"'); + + // Check for other common keys that should be included by default + expect(decodedUri).toContain(`sid="${sessionId}"`); + expect(decodedUri).toContain('cid="v2-event-content"'); + expect(decodedUri).toContain('v=2'); + expect(decodedUri).toContain('mtp='); + expect(decodedUri).toMatch(/ts=\d+/); + }); + + it('sends all event types when events array is empty', () => { + const mockMediaElement = new MockMediaElement(); + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e', 'sta'], + events: [], + }], + }; + + const cmcdManager = createCmcdManager(mockPlayerWithNE, config); + cmcdManager.setMediaElement(mockMediaElement); + + mockMediaElement.dispatchEvent(new shaka.util.FakeEvent('play')); + expect(requestSpy).toHaveBeenCalledTimes(1); + let request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + + let decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="s"'); + + mockMediaElement.dispatchEvent(new shaka.util.FakeEvent('playing')); + expect(requestSpy).toHaveBeenCalledTimes(2); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="p"'); + + mockMediaElement.muted = true; + mockMediaElement.dispatchEvent( + new shaka.util.FakeEvent('volumechange')); + expect(requestSpy).toHaveBeenCalledTimes(3); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="m"'); + + mockMediaElement.muted = false; + mockMediaElement.dispatchEvent( + new shaka.util.FakeEvent('volumechange')); + expect(requestSpy).toHaveBeenCalledTimes(4); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="um"'); + }); + + it('sends all keys for all events when both arrays are empty', () => { + const mockMediaElement = new MockMediaElement(); + const config = { + version: 2, + enabled: true, + sessionId: sessionId, + contentId: 'v2-event-content-all', + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: [], + events: [], + }], + }; + + const cmcdManager = createCmcdManager( + mockPlayerWithNE, + config, + ); + + cmcdManager.setMediaElement(mockMediaElement); + + mockMediaElement.dispatchEvent(new shaka.util.FakeEvent('play')); + expect(requestSpy).toHaveBeenCalledTimes(1); + let request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + let decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="s"'); + expect(decodedUri).toContain(`sid="${sessionId}"`); + expect(decodedUri).toContain(`cid="v2-event-content-all"`); + expect(decodedUri).toContain('v=2'); + expect(decodedUri).toMatch(/ts=\d+/); + + mockMediaElement.dispatchEvent(new shaka.util.FakeEvent('playing')); + expect(requestSpy).toHaveBeenCalledTimes(2); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="p"'); + expect(decodedUri).toContain(`sid="${sessionId}"`); + expect(decodedUri).toContain(`cid="v2-event-content-all"`); + expect(decodedUri).toContain('v=2'); + expect(decodedUri).toMatch(/ts=\d+/); + + mockMediaElement.muted = true; + mockMediaElement.dispatchEvent( + new shaka.util.FakeEvent('volumechange')); + expect(requestSpy).toHaveBeenCalledTimes(3); + request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="m"'); + expect(decodedUri).toContain(`sid="${sessionId}"`); + expect(decodedUri).toContain(`cid="v2-event-content-all"`); + expect(decodedUri).toContain('v=2'); + }); + + it('sends rebuffering play state change event', () => { + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e', 'sta', 'v'], + events: ['ps'], + }], + }; + + const cmcdManager = createCmcdManager( + mockPlayerWithNE, + config, + ); + cmcdManager.setMediaElement(new shaka.util.FakeEventTarget()); + cmcdManager.configure(config); + + // Simulate playback start + cmcdManager.setBuffering(false); + (/** @type {!jasmine.Spy} */ (requestSpy)).calls.reset(); + + // Simulate rebuffering + cmcdManager.setBuffering(true); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const request = (/** @type {!jasmine.Spy} */ (requestSpy)) + .calls.mostRecent().args[1]; + const decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="r"'); + expect(decodedUri).toContain('v=2'); + }); + + it('sends preloading event', () => { + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e', 'sta'], + events: ['ps'], + }], + }; + + const cmcdManager = createCmcdManager( + mockPlayerWithNE, + config, + ); + cmcdManager.setMediaElement(new shaka.util.FakeEventTarget()); + cmcdManager.configure(config); + + cmcdManager.setStartTimeOfLoad(Date.now()); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const request = (/** @type {!jasmine.Spy} */ (requestSpy)) + .calls.mostRecent().args[1]; + const decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="d"'); + }); + + it('sends player expand and collapse events', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e'], + events: ['pe', 'pc'], + }], + }; + + const cmcdManager = createCmcdManager( + mockPlayerWithNE, + config, + ); + cmcdManager.setMediaElement(mockVideo); + cmcdManager.configure(config); + + // Mock fullscreenElement to simulate entering fullscreen + Object.defineProperty(document, 'fullscreenElement', { + value: mockVideo, + writable: true, + }); + mockVideo.dispatchEvent(new shaka.util.FakeEvent('fullscreenchange')); + + expect(requestSpy).toHaveBeenCalledTimes(1); + let request = (/** @type {!jasmine.Spy} */ (requestSpy)) + .calls.mostRecent().args[1]; + let decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="pe"'); + + // Mock fullscreenElement to simulate exiting fullscreen + Object.defineProperty(document, 'fullscreenElement', { + value: null, + writable: true, + }); + mockVideo.dispatchEvent(new shaka.util.FakeEvent('fullscreenchange')); + + expect(requestSpy).toHaveBeenCalledTimes(2); + request = (/** @type {!jasmine.Spy} */ (requestSpy)) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="pc"'); + + // Restore original property + Object.defineProperty(document, 'fullscreenElement', { + value: null, + writable: false, + }); + }); + + it('sends complete event', () => { + const completeConfig = createCmcdConfig({ + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e', 'sta', 'v'], + events: ['ps'], + }], + }); + + const cmcdManager = new CmcdManager( + /** @type {shaka.Player} */ (mockPlayerWithNE), + completeConfig, + ); + + cmcdManager.setMediaElement( + /** @type {!HTMLMediaElement} */ + (/** @type {*} */ (new shaka.util.FakeEventTarget())), + ); + + cmcdManager.configure(completeConfig); + + mockPlayerWithNE.dispatchEvent(new shaka.util.FakeEvent('complete')); + + const request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + const decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="e"'); + expect(decodedUri).toContain('v=2'); + }); + + it('sends waiting event', () => { + const completeConfig = createCmcdConfig({ + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e', 'sta', 'v'], + events: ['ps'], + }], + }); + + const cmcdManager = new CmcdManager( + /** @type {shaka.Player} */ (mockPlayerWithNE), + completeConfig, + ); + + cmcdManager.setMediaElement( + /** @type {!HTMLMediaElement} */ + (/** @type {*} */ (new shaka.util.FakeEventTarget())), + ); + + cmcdManager.configure(completeConfig); + + mockPlayerWithNE.dispatchEvent(new shaka.util.FakeEvent('buffering')); + + const request = /** @type {!jasmine.Spy} */ (requestSpy) + .calls.mostRecent().args[1]; + const decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="ps"'); + expect(decodedUri).toContain('sta="w"'); + expect(decodedUri).toContain('v=2'); + }); + + it('sends Picture-in-Picture events', () => { + const mockVideo = new shaka.util.FakeEventTarget(); + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e'], + events: ['pe', 'pc'], + }], + }; + + const cmcdManager = createCmcdManager( + mockPlayerWithNE, + config, + ); + cmcdManager.setMediaElement(mockVideo); + cmcdManager.configure(config); + + mockVideo.dispatchEvent( + new shaka.util.FakeEvent('enterpictureinpicture')); + + expect(requestSpy).toHaveBeenCalledTimes(1); + let request = (/** @type {!jasmine.Spy} */ (requestSpy)) + .calls.mostRecent().args[1]; + let decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="pe"'); + + // Simulate leaving Picture-in-Picture + mockVideo.dispatchEvent(new shaka.util.FakeEvent( + 'leavepictureinpicture')); + + expect(requestSpy).toHaveBeenCalledTimes(2); + request = (/** @type {!jasmine.Spy} */ (requestSpy)) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="pc"'); + }); + + it('sends webkit presentation mode change events', () => { + /** + * @extends {shaka.util.FakeEventTarget} + */ + class MockWebKitVideo extends shaka.util.FakeEventTarget { + constructor() { + super(); + /** @type {string} */ + this.webkitPresentationMode = 'inline'; + } + } + const mockVideo = new MockWebKitVideo(); + + const config = { + version: 2, + enabled: true, + targets: [{ + mode: 'event', + enabled: true, + url: 'https://example.com/cmcd', + includeKeys: ['e'], + events: ['pe', 'pc'], + }], + }; + + const cmcdManager = createCmcdManager( + mockPlayerWithNE, + config, + ); + cmcdManager.setMediaElement(mockVideo); + cmcdManager.configure(config); + + // Simulate entering fullscreen via webkit presentation mode + mockVideo.webkitPresentationMode = 'fullscreen'; + mockVideo.dispatchEvent( + new shaka.util.FakeEvent('webkitpresentationmodechanged')); + + expect(requestSpy).toHaveBeenCalledTimes(1); + let request = (/** @type {!jasmine.Spy} */ (requestSpy)) + .calls.mostRecent().args[1]; + let decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="pe"'); + + // Simulate exiting fullscreen via webkit presentation mode + mockVideo.webkitPresentationMode = 'inline'; + mockVideo.dispatchEvent( + new shaka.util.FakeEvent('webkitpresentationmodechanged')); + + expect(requestSpy).toHaveBeenCalledTimes(2); + request = (/** @type {!jasmine.Spy} */ (requestSpy)) + .calls.mostRecent().args[1]; + decodedUri = decodeURIComponent(request.uris[0]); + expect(decodedUri).toContain('e="pc"'); + }); + }); }); });