@newrelic/video-core 4.1.3-beta → 4.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newrelic/video-core",
3
- "version": "4.1.3-beta",
3
+ "version": "4.1.3",
4
4
  "description": "New Relic video tracking core library",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "module": "./dist/esm/index.js",
package/src/agent.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { HarvestScheduler } from "./harvestScheduler.js";
2
2
  import { NrVideoEventAggregator } from "./eventAggregator.js";
3
+ import Constants from "./constants.js";
3
4
  import Log from "./log.js";
4
5
  import Tracker from "./tracker";
5
6
 
@@ -49,7 +50,8 @@ class VideoAnalyticsAgent {
49
50
 
50
51
  try {
51
52
  if(eventObject.actionName && eventObject.actionName === Tracker.Events.QOE_AGGREGATE) {
52
- // This makes sure that there is only one QOE aggregate event for a harvest cycle
53
+ // Ensure only one QOE_AGGREGATE event exists in buffer per harvest cycle.
54
+ // Dirty-check dedup happens at drain time in HarvestScheduler._qoeKpisUnchanged().
53
55
  return this.eventBuffer.addOrReplaceByActionName(Tracker.Events.QOE_AGGREGATE, eventObject);
54
56
  }
55
57
  return this.eventBuffer.add(eventObject);
@@ -71,6 +73,45 @@ class VideoAnalyticsAgent {
71
73
 
72
74
  this.harvestScheduler.updateHarvestInterval(interval);
73
75
  }
76
+
77
+ /**
78
+ * Forces the next harvest cycle to include QOE_AGGREGATE events.
79
+ * Called at CONTENT_END to ensure final QoE is sent.
80
+ */
81
+ forceNextQoeCycle() {
82
+ if (this.harvestScheduler) {
83
+ this.harvestScheduler.forceNextQoeCycle = true;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Sets a callback to be called before each drain to refresh QoE KPIs.
89
+ * @param {Function|null} callback - Function that refreshes QoE data in the buffer, or null to clear
90
+ */
91
+ setBeforeDrainCallback(callback) {
92
+ if (this.harvestScheduler) {
93
+ this.harvestScheduler.beforeDrainCallback = callback;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Updates QoE KPI fields on the existing QOE_AGGREGATE event in the buffer.
99
+ * Uses addOrReplaceByActionName to keep payload size tracking accurate.
100
+ * @param {object} freshKpis - Object with latest KPI values
101
+ */
102
+ refreshQoeKpis(freshKpis) {
103
+ if (!this.eventBuffer || !freshKpis) return;
104
+ const existing = this.eventBuffer.findByActionName(Tracker.Events.QOE_AGGREGATE);
105
+ if (existing) {
106
+ const updated = { ...existing };
107
+ for (const key of Constants.QOE_KPI_KEYS) {
108
+ if (key in freshKpis) {
109
+ updated[key] = freshKpis[key];
110
+ }
111
+ }
112
+ this.eventBuffer.addOrReplaceByActionName(Tracker.Events.QOE_AGGREGATE, updated);
113
+ }
114
+ }
74
115
  }
75
116
 
76
117
  // Create singleton instance
package/src/constants.js CHANGED
@@ -42,10 +42,20 @@ Constants.MAX_BEACON_SIZE = 61440; // 60KB = 60 × 1024 bytes
42
42
  Constants.MAX_EVENTS_PER_BATCH = 1000;
43
43
  Constants.INTERVAL = 10000; //10 seconds
44
44
 
45
+ Constants.QOE_KPI_KEYS = [
46
+ "startupTime", "peakBitrate", "averageBitrate", "totalPlaytime",
47
+ "totalRebufferingTime", "rebufferingRatio", "hadStartupError",
48
+ "hadPlaybackError", "numberOfErrors"
49
+ ];
50
+
45
51
  Constants.QOE_AGGREGATE_KEYS = [
46
52
  "coreVersion", "instrumentation.name",
47
53
  "instrumentation.provider", "instrumentation.version", "isBackgroundEvent", "playerName", "playerVersion",
48
- "src", "viewId", "viewSession", "contentIsAutoplayed"
54
+ "src", "viewId", "viewSession", "contentIsAutoplayed", "contentIsMuted", "contentRenditionHeight", "contentRenditionWidth",
55
+ "contentSrc", "numberOfVideos", "pageUrl", "trackerName", "trackerVersion", "contentDuration", "contentPlayrate", "contentPlayhead",
56
+ "contentPreload", "elapsedTime", "contentTitle", "contentId", "contentIsLive", "deviceType", "deviceGroup", "deviceManufacturer",
57
+ "deviceModel", "deviceName", "deviceSize", "deviceUuid", "contentRenditionName", "contentIsFullscreen", "contentCdn",
58
+ "contentFps", "asnOrganization", "asnLongitude", "asnLatitude", "asn", "timeSinceRequested", "timeSinceStarted"
49
59
  ]
50
60
 
51
61
  export default Constants;
package/src/core.js CHANGED
@@ -16,7 +16,7 @@ class Core {
16
16
  static addTracker(tracker, options) {
17
17
  // Set video analytics configuration
18
18
  if (options?.info) {
19
- setVideoConfig(options.info);
19
+ setVideoConfig(options.info, options?.config);
20
20
  }
21
21
 
22
22
  if (tracker.on && tracker.emit) {
@@ -55,6 +55,16 @@ export class NrVideoEventAggregator {
55
55
  }
56
56
  }
57
57
 
58
+ /**
59
+ * Returns the existing event in buffer matching the given actionName, or null.
60
+ * @param {string} actionName
61
+ * @returns {object|null}
62
+ */
63
+ findByActionName(actionName) {
64
+ const event = this.buffer.find(e => e.actionName === actionName);
65
+ return event || null;
66
+ }
67
+
58
68
  /**
59
69
  * Adds an event to the unified buffer.
60
70
  * All events are treated equally in FIFO order.
@@ -3,6 +3,7 @@ import { RetryQueueHandler } from "./retryQueueHandler";
3
3
  import { OptimizedHttpClient } from "./optimizedHttpClient";
4
4
  import { buildUrl, dataSize } from "./utils";
5
5
  import Constants from "./constants";
6
+ import Tracker from "./tracker";
6
7
  import Log from "./log";
7
8
 
8
9
  /**
@@ -31,6 +32,10 @@ export class HarvestScheduler {
31
32
  this.currentTimerId = null;
32
33
  this.harvestCycle = Constants.INTERVAL;
33
34
  this.isHarvesting = false;
35
+ this.qoeCycleCount = 1;
36
+ this.forceNextQoeCycle = false;
37
+ this.beforeDrainCallback = null;
38
+ this._lastSentQoeKpis = null;
34
39
 
35
40
  // Page lifecycle handling
36
41
  this.setupPageLifecycleHandlers();
@@ -237,15 +242,65 @@ export class HarvestScheduler {
237
242
  /**
238
243
  * Drains events from the event buffer and optionally includes retry queue data.
239
244
  * Uses fresh-events-first approach with payload limits.
245
+ * Filters out QOE_AGGREGATE events based on the harvest interval multiplier,
246
+ * always including them on the first and final harvest cycles.
240
247
  * @param {object} options - Harvest options
241
248
  * @returns {Array} Drained events
242
249
  * @private
243
250
  */
244
- drainEvents() {
251
+ drainEvents(options = {}) {
252
+ // Determine if this cycle should include the QOE_AGGREGATE event
253
+ const multiplier = window.NRVIDEO?.config?.qoeIntervalFactor ?? 1;
254
+ const isForced = !!options.isFinalHarvest || this.forceNextQoeCycle;
255
+ const isQoeCycle =
256
+ (this.qoeCycleCount - 1) % multiplier === 0 || isForced;
257
+
258
+ // Reset force flag after use
259
+ if (this.forceNextQoeCycle) this.forceNextQoeCycle = false;
260
+
261
+ // Refresh QoE KPIs with latest tracker state before draining
262
+ if (this.beforeDrainCallback && typeof this.beforeDrainCallback === 'function') {
263
+ try {
264
+ this.beforeDrainCallback();
265
+ } catch (e) {
266
+ Log.error("Before drain callback failed:", e.message);
267
+ }
268
+ }
269
+
245
270
  // Always drain fresh events first (priority approach)
246
271
  const freshEvents = this.eventBuffer.drain();
247
- let events = [...freshEvents];
248
- let currentPayloadSize = dataSize(freshEvents);
272
+ let filteredFreshEvents;
273
+ if (isQoeCycle) {
274
+ filteredFreshEvents = freshEvents;
275
+ } else {
276
+ // On non-QoE cycles, put QoE events back into buffer instead of losing them
277
+ filteredFreshEvents = [];
278
+ for (const e of freshEvents) {
279
+ if (e.actionName === Tracker.Events.QOE_AGGREGATE) {
280
+ this.eventBuffer.add(e);
281
+ } else {
282
+ filteredFreshEvents.push(e);
283
+ }
284
+ }
285
+ }
286
+
287
+ this.qoeCycleCount++;
288
+
289
+ // Cross-cycle dirty check: skip QoE if KPIs unchanged since last send
290
+ // Forced cycles (CONTENT_END, page unload) always send QoE
291
+ for (let i = filteredFreshEvents.length - 1; i >= 0; i--) {
292
+ const e = filteredFreshEvents[i];
293
+ if (e.actionName === Tracker.Events.QOE_AGGREGATE) {
294
+ if (!isForced && this._qoeKpisUnchanged(e)) {
295
+ filteredFreshEvents.splice(i, 1);
296
+ } else {
297
+ this._saveQoeKpis(e);
298
+ }
299
+ }
300
+ }
301
+
302
+ let events = [...filteredFreshEvents];
303
+ let currentPayloadSize = dataSize(filteredFreshEvents);
249
304
 
250
305
  // Always check retry queue if it has data - no flags needed
251
306
  if (this.retryQueueHandler && this.retryQueueHandler.getQueueSize() > 0) {
@@ -413,4 +468,30 @@ export class HarvestScheduler {
413
468
  triggerFinalHarvest();
414
469
  });
415
470
  }
471
+
472
+ /**
473
+ * Checks if QoE KPIs are unchanged since last send.
474
+ * @param {object} event - QoE event to compare
475
+ * @returns {boolean} True if KPIs are identical to last sent
476
+ * @private
477
+ */
478
+ _qoeKpisUnchanged(event) {
479
+ if (!this._lastSentQoeKpis) return false;
480
+ for (const key of Constants.QOE_KPI_KEYS) {
481
+ if (event[key] !== this._lastSentQoeKpis[key]) return false;
482
+ }
483
+ return true;
484
+ }
485
+
486
+ /**
487
+ * Saves QoE KPI values after sending.
488
+ * @param {object} event - QoE event that was sent
489
+ * @private
490
+ */
491
+ _saveQoeKpis(event) {
492
+ this._lastSentQoeKpis = {};
493
+ for (const key of Constants.QOE_KPI_KEYS) {
494
+ this._lastSentQoeKpis[key] = event[key];
495
+ }
496
+ }
416
497
  }
@@ -57,7 +57,7 @@ export function recordEvent(eventType, attributes = {}) {
57
57
  // Send to video analytics harvester
58
58
  const success = videoAnalyticsHarvester.addEvent(eventObject);
59
59
 
60
- if(qoeEventObject) {
60
+ if(qoeEventObject && window?.NRVIDEO?.config?.qoeAggregate) {
61
61
  const successQoe = videoAnalyticsHarvester.addEvent(qoeEventObject);
62
62
  return success && successQoe;
63
63
  }
@@ -10,15 +10,19 @@ const { COLLECTOR } = Constants;
10
10
  class VideoConfiguration {
11
11
  /**
12
12
  * Validates and sets the video analytics configuration.
13
- * @param {object} userConfig - User provided configuration
13
+ * @param {object} userInfo - User provided configuration
14
+ * @param {object} [config] - Optional configuration object
14
15
  * @returns {boolean} True if configuration is valid and set
15
16
  */
16
17
 
17
- setConfiguration(userInfo) {
18
+ setConfiguration(userInfo, config) {
18
19
  if (!this.validateRequiredFields(userInfo)) {
19
20
  return false;
20
21
  }
21
- this.initializeGlobalConfig(userInfo);
22
+ if (!this.validateConfigFields(config)) {
23
+ return false;
24
+ }
25
+ this.initializeGlobalConfig(userInfo, config);
22
26
  Log.notice("Video analytics configuration initialized successfully");
23
27
  return true;
24
28
  }
@@ -73,10 +77,49 @@ class VideoConfiguration {
73
77
  return true;
74
78
  }
75
79
 
80
+ /**
81
+ * Validates optional config fields.
82
+ * @param {object} config - Config to validate
83
+ * @returns {boolean} True if valid
84
+ */
85
+ validateConfigFields(config) {
86
+ if (config === null || config === undefined) {
87
+ return true;
88
+ }
89
+
90
+ if (typeof config !== "object" || Array.isArray(config)) {
91
+ Log.error("config must be an object");
92
+ return false;
93
+ }
94
+
95
+ const { qoeAggregate } = config;
96
+
97
+ if (qoeAggregate !== undefined && typeof qoeAggregate !== "boolean") {
98
+ Log.error("qoeAggregate must be a boolean");
99
+ return false;
100
+ }
101
+
102
+ return true;
103
+ }
104
+
105
+ /**
106
+ * Sanitizes qoeIntervalFactor, defaulting to 1 if the value is not a positive integer.
107
+ * @param {*} value
108
+ * @returns {number}
109
+ */
110
+ sanitizeQoeIntervalFactor(value) {
111
+ if (value === undefined || value === null) return 1;
112
+ if (typeof value === "number" && Number.isInteger(value) && value >= 1) return value;
113
+ console.warn(`[nrvideo] Invalid qoeIntervalFactor "${value}" — must be a positive integer. Defaulting to 1.`);
114
+ return 1;
115
+ }
116
+
76
117
  /**
77
118
  * Initializes the global NRVIDEO configuration object.
119
+ * @param {object} userInfo - User provided configuration
120
+ * @param {object} [config] - Optional configuration object
78
121
  */
79
- initializeGlobalConfig(userInfo) {
122
+ initializeGlobalConfig(userInfo, config) {
80
123
 
81
124
  let { licenseKey, appName, region, beacon, applicationID } = userInfo;
82
125
 
@@ -95,6 +138,10 @@ class VideoConfiguration {
95
138
  applicationID,
96
139
  ...(applicationID ? {} : { appName }), // Only include appName when no applicationID
97
140
  },
141
+ config: {
142
+ qoeAggregate: config?.qoeAggregate ?? false,
143
+ qoeIntervalFactor: this.sanitizeQoeIntervalFactor(config?.qoeIntervalFactor),
144
+ }
98
145
  };
99
146
  }
100
147
  }
@@ -104,11 +151,12 @@ const videoConfiguration = new VideoConfiguration();
104
151
 
105
152
  /**
106
153
  * Sets the video analytics configuration.
107
- * @param {object} config - Configuration object
154
+ * @param {object} info - Info configuration object
155
+ * @param {object} [config] - Optional configuration object
108
156
  * @returns {boolean} True if configuration was set successfully
109
157
  */
110
- export function setVideoConfig(info) {
111
- return videoConfiguration.setConfiguration(info);
158
+ export function setVideoConfig(info, config) {
159
+ return videoConfiguration.setConfiguration(info, config);
112
160
  }
113
161
 
114
162
  export { videoConfiguration };
@@ -1,6 +1,7 @@
1
1
  import Log from "./log";
2
2
  import Tracker from "./tracker";
3
3
  import TrackerState from "./videotrackerstate";
4
+ import { videoAnalyticsHarvester } from "./agent";
4
5
  import pkg from "../package.json";
5
6
 
6
7
  /**
@@ -239,6 +240,21 @@ class VideoTracker extends Tracker {
239
240
  return null;
240
241
  }
241
242
 
243
+ /** Override to return the manifest-declared bitrate in bps (Indicated Bitrate). */
244
+ getManifestBitrate() {
245
+ return null;
246
+ }
247
+
248
+ /** Override to return the measured network bitrate in bps (Observed Bitrate). */
249
+ getMeasuredBitrate() {
250
+ return null;
251
+ }
252
+
253
+ /** Override to return the download throughput in bps. */
254
+ getDownloadBitrate() {
255
+ return null;
256
+ }
257
+
242
258
  /** Calculates consumed bitrate using webkitVideoDecodedByteCount. */
243
259
  getWebkitBitrate() {
244
260
  if (this.tag && this.tag.webkitVideoDecodedByteCount) {
@@ -465,12 +481,15 @@ class VideoTracker extends Tracker {
465
481
  att.adTitle = this.getTitle();
466
482
  att.adSrc = this.getSrc();
467
483
  att.adCdn = this.getCdn();
468
- att.adBitrate =
469
- this.getBitrate() ||
470
- this.getWebkitBitrate() ||
471
- this.getRenditionBitrate();
484
+
485
+ // Only add bitrate attributes after ad has started
486
+ if (this.state.isStarted) {
487
+ att.adBitrate =
488
+ this.getBitrate() || 0;
489
+ att.adRenditionBitrate = this.getRenditionBitrate() || 0;
490
+ }
491
+
472
492
  att.adRenditionName = this.getRenditionName();
473
- att.adRenditionBitrate = this.getRenditionBitrate();
474
493
  att.adRenditionHeight = this.getRenditionHeight();
475
494
  att.adRenditionWidth = this.getRenditionWidth();
476
495
  att.adDuration = this.getDuration();
@@ -492,13 +511,17 @@ class VideoTracker extends Tracker {
492
511
  att.contentPlayhead = this.getPlayhead();
493
512
 
494
513
  att.contentIsLive = this.isLive();
495
- att.contentBitrate =
496
- this.getBitrate() ||
497
- this.getWebkitBitrate() ||
498
- this.getRenditionBitrate();
514
+
515
+ // Only add bitrate attributes after content has started
516
+ if (this.state.isStarted) {
517
+ att.contentBitrate = this.getBitrate()|| 0;
518
+ att.contentRenditionBitrate = this.getRenditionBitrate() || 0;
519
+ att.contentManifestBitrate = this.getManifestBitrate() || 0;
520
+ att.contentMeasuredBitrate = this.getMeasuredBitrate() || 0;
521
+ att.contentDownloadBitrate = this.getDownloadBitrate() || 0;
522
+ }
499
523
 
500
524
  att.contentRenditionName = this.getRenditionName();
501
- att.contentRenditionBitrate = this.getRenditionBitrate();
502
525
  att.contentRenditionHeight = this.getRenditionHeight();
503
526
  att.contentRenditionWidth = this.getRenditionWidth();
504
527
  att.contentDuration = this.getDuration();
@@ -610,15 +633,23 @@ class VideoTracker extends Tracker {
610
633
  if(this.adsTracker) {
611
634
  // If ads state is set to playing (ad error) after content start, reset the ad state.
612
635
  if(this.adsTracker.state.isPlaying || this.adsTracker.state.isBuffering) {
613
- totalAdsTime = this.adsTracker.state.stopAdsTime();
636
+ this.adsTracker.state.stopAdsTime();
614
637
  this.adsTracker.state.isPlaying = false;
615
638
  this.adsTracker.state.isBuffering = false;
616
- } else {
617
- totalAdsTime = this.adsTracker.state.totalAdTime() ?? 0;
618
639
  }
640
+ // Always use totalAdTime() which includes accumulator across all ads
641
+ totalAdsTime = this.adsTracker.state.totalAdTime() ?? 0;
619
642
  }
620
643
  this.state.setStartupTime(totalAdsTime)
621
644
  this.sendVideoAction(ev, att);
645
+
646
+ // Register callback to refresh QoE KPIs with latest state before each drain
647
+ videoAnalyticsHarvester.setBeforeDrainCallback(() => {
648
+ if (this.state) {
649
+ const freshKpis = this.state.getQoeAttributes({}).qoe;
650
+ videoAnalyticsHarvester.refreshQoeKpis(freshKpis);
651
+ }
652
+ });
622
653
  }
623
654
  //this.send(ev, att);
624
655
  this.startHeartbeat();
@@ -658,6 +689,11 @@ class VideoTracker extends Tracker {
658
689
  this.state.goViewCountUp();
659
690
  this.state.totalPlaytime = 0;
660
691
  if(!this.isAd()) {
692
+ // Force QoE to be included in the next harvest cycle at content end
693
+ videoAnalyticsHarvester.forceNextQoeCycle();
694
+ // Clear the before-drain callback so the next harvest doesn't overwrite
695
+ // the final QoE (already in buffer) with zeroed-out state values
696
+ videoAnalyticsHarvester.setBeforeDrainCallback(null);
661
697
  // reset the states after the view count is up
662
698
  if(this.adsTracker) this.adsTracker.state.clearTotalAdsTime();
663
699
  this.state.resetViewIdTrackedState();
@@ -104,14 +104,19 @@ class VideoTrackerState {
104
104
  this.partialAverageBitrate = 0;
105
105
 
106
106
  /**
107
- * Had Startup Failure: TRUE if CONTENT_ERROR occurs before CONTENT_START.
107
+ * Total duration (ms) of all closed bitrate segments for weighted average
108
108
  */
109
- this.hadStartupFailure = false;
109
+ this._totalBitrateDuration = 0;
110
110
 
111
111
  /**
112
- * Had Playback Failure: TRUE if CONTENT_ERROR occurs during content playback.
112
+ * Had Startup Error: TRUE if CONTENT_ERROR occurs before CONTENT_START.
113
113
  */
114
- this.hadPlaybackFailure = false;
114
+ this.hadStartupError = false;
115
+
116
+ /**
117
+ * Had Playback Error: TRUE if CONTENT_ERROR occurs during content playback.
118
+ */
119
+ this.hadPlaybackError = false;
115
120
 
116
121
  /**
117
122
  * The amount of ms the user has been rebuffering during content playback.
@@ -354,8 +359,8 @@ class VideoTrackerState {
354
359
  if (this.peakBitrate > 0) {
355
360
  kpi["peakBitrate"] = this.peakBitrate;
356
361
  }
357
- kpi["hadStartupFailure"] = this.hadStartupFailure;
358
- kpi["hadPlaybackFailure"] = this.hadPlaybackFailure;
362
+ kpi["hadStartupError"] = this.hadStartupError;
363
+ kpi["hadPlaybackError"] = this.hadPlaybackError;
359
364
  kpi["totalRebufferingTime"] = this.totalRebufferingTime;
360
365
  // Calculate rebuffering ratio as percentage (avoid division by zero)
361
366
  kpi["rebufferingRatio"] = this.totalPlaytime > 0
@@ -549,8 +554,10 @@ class VideoTrackerState {
549
554
  }
550
555
 
551
556
  // Accumulate total rebuffering time for content only
557
+ // Use exact stopped duration (stopTime - startTime) instead of live getDeltaTime()
552
558
  if (!this.isAd() && this.initialBufferingHappened) {
553
- this.totalRebufferingTime += this.timeSinceBufferBegin.getDeltaTime();
559
+ this.totalRebufferingTime +=
560
+ (this.timeSinceBufferBegin.stopTime - this.timeSinceBufferBegin.startTime);
554
561
  }
555
562
 
556
563
  return true;
@@ -670,13 +677,13 @@ class VideoTrackerState {
670
677
  } else {
671
678
  this.timeSinceLastError.start();
672
679
 
673
- // Track failure flags for content errors only
674
- // Had Startup Failure: error before content started
680
+ // Track error flags for content errors only
681
+ // Had Startup Error: error before content started
675
682
  if (!this.isStarted) {
676
- this.hadStartupFailure = true;
683
+ this.hadStartupError = true;
677
684
  } else {
678
- // Had Playback Failure: any content error
679
- this.hadPlaybackFailure = true;
685
+ // Had Playback Error: any content error during playback
686
+ this.hadPlaybackError = true;
680
687
  }
681
688
  }
682
689
  }
@@ -696,14 +703,57 @@ class VideoTrackerState {
696
703
  if (bitrate && typeof bitrate === "number") {
697
704
  this.peakBitrate = Math.max(this.peakBitrate, bitrate);
698
705
 
699
- if(this._lastBitrate === null || this._lastBitrate !== bitrate) {
700
- const deltaPlaytime = this._lastBitrateChangeTimestamp === null ? this.totalPlaytime : Date.now() - this._lastBitrateChangeTimestamp;
701
- const currentWeightedBitrate = (bitrate * deltaPlaytime);
702
- this.partialAverageBitrate += currentWeightedBitrate;
703
- this.weightedBitrate = currentWeightedBitrate / deltaPlaytime;
706
+ const now = Date.now();
707
+
708
+ // If not playing (buffering, paused, etc.), close the current segment
709
+ // so non-play time is excluded from the weighted average
710
+ if (!this.isPlaying) {
711
+ if (this._lastBitrate !== null && this._lastBitrateChangeTimestamp !== null) {
712
+ const segmentDuration = now - this._lastBitrateChangeTimestamp;
713
+ if (segmentDuration > 0) {
714
+ this.partialAverageBitrate += this._lastBitrate * segmentDuration;
715
+ this._totalBitrateDuration += segmentDuration;
716
+ }
717
+ this._lastBitrateChangeTimestamp = null; // Mark as paused
718
+ }
704
719
  this._lastBitrate = bitrate;
705
- this._lastBitrateChangeTimestamp = Date.now();
720
+ return;
721
+ }
722
+
723
+ // Playing: restart timestamp if returning from non-play state
724
+ if (this._lastBitrateChangeTimestamp === null && this._lastBitrate !== null) {
725
+ this._lastBitrateChangeTimestamp = now;
726
+ }
727
+
728
+ // Close the PREVIOUS segment when bitrate changes
729
+ if (this._lastBitrate !== null && this._lastBitrate !== bitrate
730
+ && this._lastBitrateChangeTimestamp !== null) {
731
+ const segmentDuration = now - this._lastBitrateChangeTimestamp;
732
+ if (segmentDuration > 0) {
733
+ this.partialAverageBitrate += this._lastBitrate * segmentDuration;
734
+ this._totalBitrateDuration += segmentDuration;
735
+ }
736
+ this._lastBitrateChangeTimestamp = now;
737
+ }
738
+
739
+ // Initialize timestamp on first observation
740
+ if (this._lastBitrateChangeTimestamp === null) {
741
+ this._lastBitrateChangeTimestamp = now;
742
+ }
743
+
744
+ this._lastBitrate = bitrate;
745
+
746
+ // Compute weighted average including current in-progress segment
747
+ let totalWeighted = this.partialAverageBitrate;
748
+ let totalDuration = this._totalBitrateDuration;
749
+ const currentSegment = now - this._lastBitrateChangeTimestamp;
750
+ if (currentSegment > 0) {
751
+ totalWeighted += bitrate * currentSegment;
752
+ totalDuration += currentSegment;
706
753
  }
754
+ this.weightedBitrate = totalDuration > 0
755
+ ? Math.round(totalWeighted / totalDuration)
756
+ : bitrate;
707
757
  }
708
758
  }
709
759
 
@@ -713,6 +763,7 @@ class VideoTrackerState {
713
763
  resetViewIdTrackedState() {
714
764
  this.peakBitrate = 0;
715
765
  this.partialAverageBitrate = 0;
766
+ this._totalBitrateDuration = 0;
716
767
  this.startupTime = null;
717
768
  this._lastBitrate = null;
718
769
  this._lastBitrateChangeTimestamp = null;
@@ -720,7 +771,7 @@ class VideoTrackerState {
720
771
 
721
772
  /** Methods to manage total ads time chrono */
722
773
  clearTotalAdsTime() {
723
- console.log("clear total ads time", this.totalAdTime);
774
+ Log.debug("clear total ads time", this.totalAdTime);
724
775
  this._totalAdPlaytime.reset();
725
776
  }
726
777
 
@@ -729,12 +780,12 @@ class VideoTrackerState {
729
780
  }
730
781
 
731
782
  startAdsTime() {
732
- console.log("startAdsTime");
783
+ Log.debug("startAdsTime");
733
784
  return this._totalAdPlaytime.start();
734
785
  }
735
786
 
736
787
  stopAdsTime() {
737
- console.log("stopAdsTime");
788
+ Log.debug("stopAdsTime");
738
789
  return this._totalAdPlaytime.stop();
739
790
  }
740
791