diff --git a/.changeset/happy-beans-hammer.md b/.changeset/happy-beans-hammer.md
new file mode 100644
index 00000000..a845151c
--- /dev/null
+++ b/.changeset/happy-beans-hammer.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.idea/web-connectors.iml b/.idea/web-connectors.iml
index 4e0bc000..a92d84c5 100644
--- a/.idea/web-connectors.iml
+++ b/.idea/web-connectors.iml
@@ -8,6 +8,7 @@
+
diff --git a/cmcd/.gitignore b/cmcd/.gitignore
new file mode 100644
index 00000000..bf62141d
--- /dev/null
+++ b/cmcd/.gitignore
@@ -0,0 +1,24 @@
+# These are some examples of commonly ignored file patterns.
+# You should customize this list as applicable to your project.
+# Learn more about .gitignore:
+# https://www.atlassian.com/git/tutorials/saving-changes/gitignore
+
+# Node artifact files
+node_modules/
+lib/
+dist/
+
+# JetBrains IDE
+.idea/
+
+# Unit test reports
+TEST*.xml
+
+# Generated by MacOS
+.DS_Store
+
+# Generated by Windows
+Thumbs.db
+
+# THEOplayer build and TypeScript definitions
+local/
diff --git a/cmcd/CHANGELOG.md b/cmcd/CHANGELOG.md
new file mode 100644
index 00000000..a12c37cc
--- /dev/null
+++ b/cmcd/CHANGELOG.md
@@ -0,0 +1,7 @@
+# @theoplayer/cmcd-connector-web
+
+## 1.0.0
+
+### ✨ Features
+
+- Initial release
diff --git a/cmcd/LICENSE.md b/cmcd/LICENSE.md
new file mode 100644
index 00000000..146db44c
--- /dev/null
+++ b/cmcd/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 THEO Technologies NV
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/cmcd/README.md b/cmcd/README.md
new file mode 100644
index 00000000..b69968ac
--- /dev/null
+++ b/cmcd/README.md
@@ -0,0 +1,88 @@
+# cmcd-connector-web
+
+A connector between a THEOplayer instance and a Common Media Client Data (CMCD) server for the THEOplayer
+HTML5/Tizen/webOS SDK. This implementation supports CMCD data as defined in CTA-5004, published in September 2020.
+
+## Prerequisites
+In order to use this connector, a [THEOplayer](https://www.npmjs.com/package/theoplayer) build with a valid license is required. You can use your existing THEOplayer HTML5 SDK license or request yours via [THEOportal](https://portal.theoplayer.com/).
+
+## Installation
+
+Install using your favorite package manager for Node (such as `npm` or `yarn`):
+
+### Install via npm
+
+```bash
+npm install @theoplayer/cmcd-connector-web
+```
+
+### Install via yarn
+
+```bash
+yarn add @theoplayer/cmcd-connector-web
+```
+
+## Usage
+
+First you need to add the CMCD connector to your app :
+
+* Add as a regular script
+
+```html
+
+
+```
+
+* Add as an ES2015 module
+
+```html
+
+```
+
+By default, the data is sent via query arguments, but you can configure the transmission mode before creating the CMCD connector. For example, to transmit via HTTP headers:
+
+* regular script
+
+```html
+
+
+```
+
+* ES2015 module
+
+```html
+
+```
+
+The connector will be automatically destroyed upon destruction of the provided player. When changing the player source and a content ID is
+being passed in, this is to be reset through `reconfigure()` as it will not be cleared automatically.
+
+## Remarks
+Note that when native playback is being used, either through THEOplayer's configuration, or due to absence of MSE/EME
+APIs, the JSON Object transmission mode should be used.
+
+Currently, all standardized reserved keys are reported, except:
+
+- Object duration (`d`)
+- Next object request (`nor`)
+- Next range request (`nrr`)
diff --git a/cmcd/jest.config.js b/cmcd/jest.config.js
new file mode 100644
index 00000000..78e93fe8
--- /dev/null
+++ b/cmcd/jest.config.js
@@ -0,0 +1,4 @@
+module.exports = {
+ preset: "ts-jest",
+ testEnvironment: "node"
+};
diff --git a/cmcd/package.json b/cmcd/package.json
new file mode 100644
index 00000000..e309cd75
--- /dev/null
+++ b/cmcd/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@theoplayer/cmcd-connector-web",
+ "version": "1.0.0",
+ "description": "A connector implementing CMCD support for web.",
+ "main": "dist/cmcd-connector.umd.js",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/THEOplayer/web-connectors.git",
+ "directory": "cmcd"
+ },
+ "homepage": "https://theoplayer.com/",
+ "module": "dist/cmcd-connector.esm.js",
+ "types": "dist/cmcd-connector.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/cmcd-connector.d.ts",
+ "import": "./dist/cmcd-connector.esm.js",
+ "require": "./dist/cmcd-connector.umd.js"
+ },
+ "./dist/*": "./dist/*",
+ "./package": "./package.json",
+ "./package.json": "./package.json"
+ },
+ "scripts": {
+ "clean": "rimraf lib dist",
+ "bundle": "rollup -c rollup.config.mjs",
+ "build": "npm run clean && npm run bundle",
+ "serve": "http-server",
+ "test": "jest"
+ },
+ "author": "THEO Technologies NV",
+ "license": "MIT",
+ "files": [
+ "dist/",
+ "CHANGELOG.md",
+ "README.md",
+ "LICENSE.md",
+ "package.json"
+ ],
+ "peerDependencies": {
+ "theoplayer": "^5.0.0 || ^6.0.0"
+ }
+}
diff --git a/cmcd/rollup.config.mjs b/cmcd/rollup.config.mjs
new file mode 100644
index 00000000..570798e8
--- /dev/null
+++ b/cmcd/rollup.config.mjs
@@ -0,0 +1,13 @@
+import fs from "node:fs";
+import {getSharedBuildConfiguration} from "../tools/build.mjs";
+
+const {version} = JSON.parse(fs.readFileSync("./package.json", "utf8"));
+
+const fileName = "cmcd-connector";
+const globalName = "THEOplayerCMCDConnector"
+const banner = `
+/**
+ * THEOplayer CMCD Connector v${version}
+ */`.trim();
+
+export default getSharedBuildConfiguration(fileName, globalName, banner);
diff --git a/cmcd/src/CMCDCollector.ts b/cmcd/src/CMCDCollector.ts
new file mode 100644
index 00000000..3458829f
--- /dev/null
+++ b/cmcd/src/CMCDCollector.ts
@@ -0,0 +1,353 @@
+import { ChromelessPlayer, CurrentSourceChangeEvent, MediaTrack, MediaType, Quality, Request } from 'theoplayer';
+import { CMCDObjectType, CMCDPayload, CMCDReservedKey, CMCDStreamingFormat, CMCDStreamType } from './CMCDPayload';
+import { Configuration } from './Configuration';
+import { calculateBufferSize, getStreamingFormatFromTypedSource } from './PlayerUtils';
+import { uuid } from './RandomUtils';
+
+const REQUESTED_MAXIMUM_THROUGHPUT_SAFETY_FACTOR = 5;
+const BUFFER_STARVATION_MARGIN = 0.2;
+
+/**
+ * Collector for all CMCD data. This object will subscribe to player events and observe player behaviour in order to collect
+ * the correct data. When resetting the player, this object must be recycled as well.
+ */
+export class CMCDCollector {
+ private readonly _config: Configuration;
+ private readonly _player: ChromelessPlayer;
+ private _bufferStarved: boolean = false;
+ private _streamType: CMCDStreamType | undefined;
+ private _streamingFormat: CMCDStreamingFormat | undefined;
+
+ /**
+ * Creates a new CMCD data collector for the given player and the given configuration.
+ * @param player The player for which data is to be collected.
+ * @param config The configuration containing the sessionID, contentID and optional custom parameters.
+ */
+ constructor(player: ChromelessPlayer, config: Configuration) {
+ this._player = player;
+ this._config = config;
+ player.addEventListener('waiting', this.handleWaiting_);
+ player.addEventListener('durationchange', this.handleDurationChange_);
+ player.addEventListener('currentsourcechange', this.handleCurrentSourceChange_);
+ }
+
+ /**
+ * Handler for player `waiting` events in order to mark buffer starvation.
+ * @private
+ */
+ private handleWaiting_ = () => {
+ if (calculateBufferSize(this._player.currentTime, this._player.buffered) < BUFFER_STARVATION_MARGIN) {
+ this._bufferStarved = true;
+ }
+ };
+
+ /**
+ * Handler for player `durationchange` events in order to identify the stream type.
+ * @private
+ */
+ private handleDurationChange_ = () => {
+ if (this._player.duration) {
+ this._streamType = isFinite(this._player.duration) ? CMCDStreamType.STATIC : CMCDStreamType.DYNAMIC;
+ } else {
+ this._streamType = undefined;
+ }
+ };
+
+ /**
+ * Handler for player `currentsourcechange` events in order to identify the streaming format.
+ * @private
+ */
+ private handleCurrentSourceChange_ = (event: CurrentSourceChangeEvent) => {
+ const currentSource = event.currentSource;
+ if (!currentSource) {
+ this._streamingFormat = undefined;
+ } else {
+ this._streamingFormat = getStreamingFormatFromTypedSource(currentSource);
+ }
+ };
+
+ /**
+ * Collection method for all reserved keys of the SESSION-type and the configured custom keys.
+ * @returns A payload object containing all custom keys and all reserved keys of the SESSION-type other than VERSION (which currently is 1 so it must be omitted).
+ * @private
+ */
+ private collectSessionKeys(): CMCDPayload {
+ let payload: CMCDPayload = {
+ // [CMCDReservedKey.VERSION]: 1 // Client SHOULD omit this field if the version is 1.
+ };
+
+ if (this._config.sessionID) {
+ payload[CMCDReservedKey.SESSION_ID] = this._config.sessionID;
+ }
+
+ if (this._config.contentID) {
+ payload[CMCDReservedKey.CONTENT_ID] = this._config.contentID;
+ }
+
+ if (this._player.playbackRate !== 1) {
+ payload[CMCDReservedKey.PLAYBACK_RATE] = this._player.playbackRate;
+ }
+
+ if (this._streamType) {
+ payload[CMCDReservedKey.STREAM_TYPE] = this._streamType;
+ }
+
+ if (this._streamingFormat) {
+ payload[CMCDReservedKey.STREAMING_FORMAT] = this._streamingFormat;
+ }
+
+ if (this._config.customKeys) {
+ payload = {
+ ...payload,
+ ...this._config.customKeys
+ };
+ }
+
+ return payload;
+ }
+
+ /**
+ * Collection method for all reserved keys of the STATUS-type.
+ * @returns A payload object containing all reserved keys of the STATUS-type.
+ * @private
+ */
+ collectStatusKeys(track: MediaTrack | undefined): CMCDPayload {
+ const payload: CMCDPayload = {};
+
+ if (this._bufferStarved) {
+ payload[CMCDReservedKey.BUFFER_STARVATION] = true;
+ this._bufferStarved = false;
+ }
+
+ const activeQuality = track?.activeQuality;
+ const activeBandwidth = activeQuality?.bandwidth;
+ const targetBuffer = this._player.abr.targetBuffer;
+ if (activeBandwidth && targetBuffer) {
+ payload[CMCDReservedKey.REQUESTED_MAXIMUM_THROUGHPUT] = calculateRequestedMaximumThroughput(
+ this._player.currentTime,
+ this._player.buffered,
+ targetBuffer,
+ this._player.playbackRate,
+ activeBandwidth
+ );
+ }
+
+ return payload;
+ }
+
+ /**
+ * Collects all the CMCD parameters as supported by the connector depending on the provided {@link Request}.
+ * @param request The request for which CMCD parameters should be collected.
+ * @returns A payload object containing keys for the provided request.
+ */
+ collect(request: Request): CMCDPayload {
+ const sessionKeys = this.collectSessionKeys();
+ const requestKeys = this._config.sendRequestID
+ ? {
+ [CMCDReservedKey.SVA_REQUEST_ID]: uuid()
+ }
+ : {};
+ switch (request.type) {
+ case 'manifest': {
+ return {
+ ...sessionKeys,
+ ...requestKeys,
+ [CMCDReservedKey.OBJECT_TYPE]: CMCDObjectType.INIT_SEGMENT
+ };
+ }
+ case 'content-protection': {
+ return {
+ ...sessionKeys,
+ ...requestKeys,
+ [CMCDReservedKey.OBJECT_TYPE]: CMCDObjectType.KEY_LICENSE_OR_CERTIFICATE
+ };
+ }
+ case 'segment':
+ if (request.subType === 'initialization-segment') {
+ return {
+ ...sessionKeys,
+ ...requestKeys,
+ [CMCDReservedKey.STARTUP]: true,
+ [CMCDReservedKey.OBJECT_TYPE]: CMCDObjectType.INIT_SEGMENT
+ };
+ }
+
+ const bufferSize = calculateBufferSize(this._player.currentTime, this._player.buffered);
+ // TODO could be better if we have segment info...
+ const track =
+ (request.mediaType === 'audio' && this._player.audioTracks[0]) ||
+ (request.mediaType === 'video' && this._player.videoTracks[0]) ||
+ undefined;
+
+ const statusKeys = this.collectStatusKeys(track);
+ const payload: CMCDPayload = {
+ ...sessionKeys,
+ ...statusKeys,
+ ...requestKeys,
+ [CMCDReservedKey.OBJECT_TYPE]: getObjectType(request.mediaType) // TODO could be better if we have segment info...
+ };
+
+ if (track?.activeQuality) {
+ // Encoded bitrate in kbps
+ if (track.activeQuality.bandwidth) {
+ payload[CMCDReservedKey.ENCODED_BITRATE] = Math.round(track.activeQuality.bandwidth / 1000);
+ }
+
+ const playableQualities: Quality[][] = track.qualities
+ .slice()
+ .sort(perceptualQualityCompareFunction)
+ .filter(isPlayableFilter.bind(null, request.mediaType))
+ .reduce(redundantQualityGrouper, []);
+ payload[CMCDReservedKey.SVA_PLAYABLE_MANIFEST_INDEX] = playableQualities.length;
+ payload[CMCDReservedKey.SVA_CURRENT_MANIFEST_INDEX] =
+ findQualityGroupIndex(playableQualities, track.activeQuality) + 1;
+
+ payload[CMCDReservedKey.SVA_TRACK_IDENTIFIER] = track.id ?? track.uid;
+ }
+
+ // Top bitrate in kbps
+ if (track?.qualities) {
+ const topQuality = track.qualities[track.qualities.length - 1];
+ payload[CMCDReservedKey.TOP_BITRATE] = Math.round(topQuality.bandwidth / 1000);
+ }
+
+ // Measured throughput rounded to the closest 100kbps in kbps
+ // NOTE only the LL-HLS pipeline realy uses the estimator properly today, so fall back to metrics API for others
+ const measuredBandwidth =
+ this._player.network.estimator.bandwidth || this._player.metrics.currentBandwidthEstimate;
+ const measuredBandwidthInKbps = measuredBandwidth / 1000;
+ payload[CMCDReservedKey.MEASURED_THROUGHPUT] = (measuredBandwidthInKbps / 100 + 1) * 100;
+
+ // Deadline rounded to the closest 100ms in ms.
+ payload[CMCDReservedKey.DEADLINE] = Math.round((bufferSize / this._player.playbackRate) * 10) * 100;
+
+ // Buffer length rounded to the closest 100ms in ms.
+ payload[CMCDReservedKey.BUFFER_LENGTH] = Math.round(bufferSize * 10) * 100;
+
+ // Startup flag if buffer is empty due to startup, seeking or starvation.
+ if (bufferSize < BUFFER_STARVATION_MARGIN) {
+ payload[CMCDReservedKey.STARTUP] = true;
+ if (this._player.played.length === 0) {
+ delete payload[CMCDReservedKey.BUFFER_STARVATION];
+ }
+ }
+
+ // TODO object duration
+ // TODO next request... and next range
+ return payload;
+ default:
+ return {};
+ }
+ }
+
+ /**
+ * Destruction method responsible to clear up any event listeners.
+ */
+ destroy(): void {
+ this._player.removeEventListener('waiting', this.handleWaiting_);
+ this._player.removeEventListener('durationchange', this.handleDurationChange_);
+ this._player.removeEventListener('currentsourcechange', this.handleCurrentSourceChange_);
+ }
+}
+
+/**
+ * Maps the provided {@link MediaType} to the corresponding {@link CMCDObjectType}.
+ * @param mediaType The media type for which the object type should be retrieved.
+ */
+function getObjectType(mediaType: MediaType): CMCDObjectType {
+ switch (mediaType) {
+ case 'audio':
+ return CMCDObjectType.AUDIO;
+ case 'video':
+ return CMCDObjectType.VIDEO;
+ case 'text':
+ return CMCDObjectType.CAPTION_OR_SUBTITLE;
+ case 'image':
+ default:
+ return CMCDObjectType.OTHER;
+ }
+}
+
+/**
+ * Calculates the maximum throughput which should be provided. This throughput is calculated as the maximum between the current
+ * data consumption rate and the rate needed to achieve the target buffer size in a timely fashion. The resulting value is multiplied
+ * with {@link REQUESTED_MAXIMUM_THROUGHPUT_SAFETY_FACTOR} in order to ensure a high enough bandwidth is marked.
+ * The resulting value is returned rounded up to the next 100kbps in kbps.
+ * @param currentTime The current time which is used to calculate the current buffer size.
+ * @param buffered The time ranges used to calculate the current buffer size.
+ * @param targetBuffer The target buffer which is to be achieved.
+ * @param playbackRate The rate at which the player is currently playing.
+ * @param activeBandwidth The bandwidth for which the player is currently downloading data.
+ */
+function calculateRequestedMaximumThroughput(
+ currentTime: number,
+ buffered: TimeRanges,
+ targetBuffer: number,
+ playbackRate: number,
+ activeBandwidth: number
+) {
+ const bufferSize = calculateBufferSize(currentTime, buffered);
+ const dataConsumptionRate = activeBandwidth * playbackRate;
+ const dataRequiredToReachTargetBuffer = activeBandwidth * (targetBuffer - bufferSize);
+ const minimumBandwidth = Math.max(dataConsumptionRate, dataRequiredToReachTargetBuffer);
+ const requestedMaximumThroughput = minimumBandwidth * REQUESTED_MAXIMUM_THROUGHPUT_SAFETY_FACTOR;
+ const requestedMaximumThroughputInKbps = requestedMaximumThroughput / 1000;
+ return (requestedMaximumThroughputInKbps / 100 + 1) * 100;
+}
+
+/**
+ * Compare function which compares two qualities based on their assumed perceptual quality as based on the available
+ * bitrate.
+ * @param first The first element for this comparison.
+ * @param second The second element for this comparison.
+ */
+function perceptualQualityCompareFunction(first: Quality | undefined, second: Quality): number {
+ return (first?.bandwidth ?? 0) - second.bandwidth;
+}
+
+/**
+ * Filter returning true if the quality provided can be played if it's from the given type and false otherwise.
+ * @param type The media type of the quality.
+ * @param quality The quality which we want to test.
+ */
+function isPlayableFilter(type: MediaType, quality: Quality): boolean {
+ switch (type) {
+ case 'audio':
+ return MediaSource.isTypeSupported(`audio/mp4; codecs="${quality.codecs}"`);
+ case 'video':
+ return MediaSource.isTypeSupported(`video/mp4; codecs="${quality.codecs}"`);
+ default:
+ return true;
+ }
+}
+
+/**
+ * Reducer which can be used to reduce a list of qualities into a list of qualities grouped based on perceptual quality.
+ * Assumes the list being looped is sorted by perceptual quality and an empty list is provided on the first run.
+ * @param list The current list of already grouped qualities.
+ * @param quality The quality to group.
+ */
+function redundantQualityGrouper(list: Quality[][], quality: Quality): Quality[][] {
+ const lastGroup: Quality[] | undefined = list[list.length - 1];
+ const lastGroupElement: Quality | undefined = lastGroup?.[0];
+ if (lastGroup && perceptualQualityCompareFunction(lastGroupElement, quality) === 0) {
+ lastGroup.push(quality);
+ } else {
+ list.push([quality]);
+ }
+ return list;
+}
+
+/**
+ * Returns the zero-based index of the group to which the provided quality belongs. Behaves similar to `Array.indexOf`.
+ * @param qualityGroups The list of quality groups in which we try to find the index.
+ * @param quality The quality for which the group index is sought.
+ */
+function findQualityGroupIndex(qualityGroups: Quality[][], quality: Quality): number {
+ for (let index = 0; index < qualityGroups.length; index += 1) {
+ if (qualityGroups[index].indexOf(quality) !== -1) {
+ return index;
+ }
+ }
+ return -1;
+}
diff --git a/cmcd/src/CMCDConnector.ts b/cmcd/src/CMCDConnector.ts
new file mode 100644
index 00000000..c4104a8a
--- /dev/null
+++ b/cmcd/src/CMCDConnector.ts
@@ -0,0 +1,125 @@
+import { ChromelessPlayer, InterceptableRequest, Request } from 'theoplayer';
+import { CMCDCollector } from './CMCDCollector';
+import { CMCDPayload } from './CMCDPayload';
+import { Configuration, TransmissionMode } from './Configuration';
+import { uuid } from './RandomUtils';
+import {
+ createTransmissionModeStrategyFor,
+ TransmissionModeStrategy
+} from './TransmissionModeStrategies/TransmissionModeStrategy';
+
+/**
+ * Type for processors which are triggered before a CMCD payload is transmitted.
+ * The `payload` parameter contains the actual payload which will be transmitted.
+ * The `request` parameter contains the request for which the payload was generated. Note this is before any addition of
+ * payload data if it will be piggy backing on this request.
+ * The returned payload should be the payload which will be transmitted.
+ */
+export type CMCDPayloadProcessor = (payload: CMCDPayload, request: Request) => CMCDPayload;
+
+/**
+ * The connector between a THEOplayer Player instance and a Common Media Client Data (CMCD) server.
+ * This implementation supports CMCD data as defined in CTA-5004, published in September 2020.
+ *
+ * Note that when native playback is being used, either through THEOplayer's configuration, or due to absence of MSE/EME APIs
+ * (such as on iOS Safari), the {@link TransmissionMode.JSON_OBJECT} should be used.
+ *
+ * All standardized reserved keys are reported, except:
+ * - Object duration (d)
+ * - Next object request (nor)
+ * - Next range request (nrr)
+ */
+export class CMCDConnector {
+ private _transmissionModeStrategy: TransmissionModeStrategy | undefined;
+ private _collector: CMCDCollector | undefined;
+ private readonly _player: ChromelessPlayer;
+ private _processor: CMCDPayloadProcessor | undefined;
+ private readonly _sessionID: string;
+
+ /**
+ * Creates a new connector for the given player, following the provided configuration. If no session ID is provided,
+ * the session ID will be set to a random UUIDv4.
+ * @param player The THEOplayer.Player instance for which common media client data is to be reported.
+ * @param configuration The {@link Configuration} detailing how the data is to be logged. When no configuration is provided,
+ * the {@link TransmissionMode.QUERY_ARGUMENT} transmission mode will be used in order to avoid CORS preflight requests in browsers.
+ */
+ constructor(player: ChromelessPlayer, configuration?: Configuration) {
+ this._player = player;
+ this._sessionID = configuration?.sessionID || uuid();
+ this.reconfigure(configuration);
+ player.network.addRequestInterceptor(this.interceptor_);
+ player.addEventListener('destroy', this.onDestroy_);
+ }
+
+ /**
+ * The interceptor which ensures a payload is constructed for every request which should be fired.
+ *
+ * @param request The request which is fired.
+ */
+ private interceptor_ = (request: InterceptableRequest) => {
+ if (this._collector === undefined || this._transmissionModeStrategy === undefined) {
+ return;
+ }
+
+ let payload = this._collector.collect(request);
+ if (this._processor) {
+ payload = this._processor(payload, request);
+ }
+ this._transmissionModeStrategy.transmitPayload(request, payload); // TODO add callback/processor
+ };
+
+ /**
+ * Handler to observe player destruction and automatically destroy the connector and all created objects.
+ */
+ private onDestroy_ = () => {
+ this.destroy();
+ };
+
+ /**
+ * Resets the configuration of the connector. The connector will halt all transmissions and new transmissions will be
+ * made as per the updated configuration. If no new session ID is provided, the previous session ID will be reused.
+ * @param configuration The {@link Configuration} detailing how the data is to be logged. When no configuration is provided,
+ * the {@link TransmissionMode.QUERY_ARGUMENT} transmission mode will be used in order to avoid CORS preflight requests in browsers.
+ */
+ reconfigure(configuration?: Configuration): void {
+ const config: Configuration = {
+ transmissionMode: TransmissionMode.QUERY_ARGUMENT,
+ sessionID: this._sessionID,
+ ...configuration
+ };
+ this._collector?.destroy();
+ this._collector = new CMCDCollector(this._player, config);
+ this._transmissionModeStrategy = createTransmissionModeStrategyFor(config);
+ }
+
+ /**
+ * Returns the current processor which will be called before transmitting any CMCD payload, or undefined if no
+ * processor is known.
+ */
+ get processor(): CMCDPayloadProcessor | undefined {
+ return this._processor;
+ }
+
+ /**
+ * Modifies the current processor which will be called before transmitting any CMCD payload data. This value can be
+ * set to `undefined` to remove the current processor.
+ * @param value The processor which must be used for any subsequent payload about to be transmitted or `undefined` if
+ * no processor is to be used.
+ */
+ set processor(value: CMCDPayloadProcessor | undefined) {
+ this._processor = value;
+ }
+
+ /**
+ * Stops all operation of the connector.
+ */
+ destroy(): void {
+ this._player.removeEventListener('destroy', this.onDestroy_);
+ this._player.network.removeRequestInterceptor(this.interceptor_);
+ this._collector?.destroy();
+ }
+}
+
+export function createCMCDConnector(player: ChromelessPlayer, configuration?: Configuration): CMCDConnector {
+ return new CMCDConnector(player, configuration);
+}
diff --git a/cmcd/src/CMCDPayload.ts b/cmcd/src/CMCDPayload.ts
new file mode 100644
index 00000000..3d96c902
--- /dev/null
+++ b/cmcd/src/CMCDPayload.ts
@@ -0,0 +1,290 @@
+/**
+ * The definition of payload object containing all CMCD data.
+ * Note custom keys MUST carry a hyphenated prefix to ensure that there will not be a namespace collision with future
+ * revisions to the specification. Clients SHOULD use a reverse-DNS syntax when defining their own prefix.
+ */
+export type CMCDPayload = {
+ [CMCDReservedKey.ENCODED_BITRATE]?: number;
+ [CMCDReservedKey.BUFFER_LENGTH]?: number;
+ [CMCDReservedKey.BUFFER_STARVATION]?: boolean;
+ [CMCDReservedKey.CONTENT_ID]?: string;
+ [CMCDReservedKey.OBJECT_DURATION]?: number;
+ [CMCDReservedKey.DEADLINE]?: number;
+ [CMCDReservedKey.MEASURED_THROUGHPUT]?: number;
+ [CMCDReservedKey.NEXT_OBJECT_REQUEST]?: string;
+ [CMCDReservedKey.NEXT_RANGE_REQUEST]?: string;
+ [CMCDReservedKey.OBJECT_TYPE]?: CMCDObjectType;
+ [CMCDReservedKey.PLAYBACK_RATE]?: number;
+ [CMCDReservedKey.REQUESTED_MAXIMUM_THROUGHPUT]?: number;
+ [CMCDReservedKey.STREAMING_FORMAT]?: CMCDStreamingFormat;
+ [CMCDReservedKey.SESSION_ID]?: string;
+ [CMCDReservedKey.STREAM_TYPE]?: CMCDStreamType;
+ [CMCDReservedKey.STARTUP]?: boolean;
+ [CMCDReservedKey.TOP_BITRATE]?: number;
+ [CMCDReservedKey.VERSION]?: number;
+
+ [CMCDReservedKey.SVA_REQUEST_ID]?: string;
+ [CMCDReservedKey.SVA_CURRENT_MANIFEST_INDEX]?: number;
+ [CMCDReservedKey.SVA_PLAYABLE_MANIFEST_INDEX]?: number;
+ [CMCDReservedKey.SVA_TRACK_IDENTIFIER]?: string;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ [customKey: string]: any;
+};
+
+/**
+ * The possible header names as defined in the CTA-5004 specification.
+ */
+export enum CMCDHeaderName {
+ CMCD_OBJECT = 'CMCD-Object',
+ CMCD_REQUEST = 'CMCD-Request',
+ CMCD_STATUS = 'CMCD-Status',
+ CMCD_SESSION = 'CMCD-Session'
+}
+
+/**
+ * The reserved keys as defined in the CTA-5004 specification.
+ */
+export enum CMCDReservedKey {
+ /**
+ * The encoded bitrate of the audio or video object being requested. This may not be known precisely by the player,
+ * however it MAY be estimated based upon playlist/manifest declarations.
+ * If the playlist declares both peak and average bitrate values, the peak value should be transmitted.
+ *
+ * Exposed as an Integer in kbps.
+ */
+ ENCODED_BITRATE = 'br',
+ /**
+ * The buffer length associated with the media object being requested.
+ * This value MUST be rounded to the nearest 100 ms.
+ * This key SHOULD only be sent with an object type of ‘a’,‘v’ or ‘av’.
+ *
+ * Exposed as an Integer in ms.
+ */
+ BUFFER_LENGTH = 'bl',
+ /**
+ * Key is included without a value if the buffer was starved at some point between the prior request and this object request,
+ * resulting in the player being in a rebuffering state and the video or audio playback being stalled.
+ * This key MUST NOT be sent if the buffer was not starved since the prior request.
+ *
+ * If the object type ‘ot’ key is sent along with this key, then the ‘bs’ key refers to the buffer associated with the particular object type.
+ * If no object type is communicated, then the buffer state applies to the current session.
+ */
+ BUFFER_STARVATION = 'bs',
+ /**
+ * A unique string identifying the current content. Maximum length is 64 characters.
+ * This value is consistent across multiple different sessions and devices and is defined and updated at the discretion of the service provider.
+ */
+ CONTENT_ID = 'cid',
+ /**
+ * The playback duration in milliseconds of the object being requested.
+ * If a partial segment is being requested, then this value MUST indicate the playback duration of that part and not that of its parent segment.
+ * This value can be an approximation of the estimated duration if the explicit value is not known.
+ *
+ * Exposed as an Integer in ms.
+ */
+ OBJECT_DURATION = 'd',
+ /**
+ * Deadline from the request time until the first sample of this Segment/Object needs to be available in order to not
+ * create a buffer underrun or any other playback problems. This value MUST be rounded to the nearest 100ms.
+ * For a playback rate of 1, this may be equivalent to the player’s remaining buffer length.
+ *
+ * Exposed as an Integer in ms.
+ */
+ DEADLINE = 'dl',
+ /**
+ * The throughput between client and server, as measured by the client and MUST be rounded to the nearest 100 kbps.
+ * This value, however derived, SHOULD be the value that the client is using to make its next Adaptive Bitrate switching decision.
+ * If the client is connected to multiple servers concurrently, it must take care to report only the throughput measured against the receiving server.
+ * If the client has multiple concurrent connections to the server, then the intent is that this value communicates the
+ * aggregate throughput the client sees across all those connections.
+ *
+ * Exposed as an Integer in kbps.
+ */
+ MEASURED_THROUGHPUT = 'mtp',
+ /**
+ * Relative path of the next object to be requested. This can be used to trigger pre-fetching by the CDN.
+ * This MUST be a path relative to the current request. This string MUST be URLEncoded.
+ * The client SHOULD NOT depend upon any pre-fetch action being taken - it is merely a request for such a pre-fetch to take place.
+ */
+ NEXT_OBJECT_REQUEST = 'nor',
+ /**
+ * If the next request will be a partial object request, then this string denotes the byte range to be requested.
+ * If the ‘nor’ field is not set, then the object is assumed to match the object currently being requested.
+ * The client SHOULD NOT depend upon any pre-fetch action being taken - it is merely a request for such a pre-fetch to take place.
+ * Formatting is similar to the HTTP Range header, except that the unit MUST be ‘byte’, the ‘Range:’ prefix is NOT
+ * required and specifying multiple ranges is NOT allowed.
+ *
+ * Valid combinations are:
+ * "-"
+ * "-"
+ * "-"
+ */
+ NEXT_RANGE_REQUEST = 'nrr',
+ /**
+ * The media type of the current object being requested.
+ * If the object type being requested is unknown, then this key MUST NOT be used.
+ */
+ OBJECT_TYPE = 'ot',
+ /**
+ * The playback rate at which is currently being played.
+ * 1 if real-time,
+ * 2 if double speed,
+ * 0 if not playing.
+ * SHOULD only be sent if not equal to 1.
+ */
+ PLAYBACK_RATE = 'pr',
+ /**
+ * The requested maximum throughput that the client considers sufficient for delivery of the asset.
+ * Values MUST be rounded to the nearest 100kbps. For example, a client would indicate that the current segment,
+ * encoded at 2Mbps, is to be delivered at no more than 10Mbps, by using rtp=10000.
+ *
+ * Note: This can benefit clients by preventing buffer saturation through over-delivery and can also deliver a
+ * community benefit through fair-share delivery. The concept is that each client receives the throughput necessary
+ * for great performance, but no more. The CDN may not support the rtp feature.
+ *
+ * Exposed as an Integer in kbps.
+ */
+ REQUESTED_MAXIMUM_THROUGHPUT = 'rtp',
+ /**
+ * The streaming format which defines the current request
+ * If the streaming format being requested is unknown, then this key MUST NOT be used.
+ */
+ STREAMING_FORMAT = 'sf',
+ /**
+ * A GUID identifying the current playback session. A playback session typically ties together segments belonging to a single media asset.
+ * Maximum length is 64 characters. It is RECOMMENDED to conform to the UUID specification.
+ */
+ SESSION_ID = 'sid',
+ /**
+ * The type of stream being played.
+ */
+ STREAM_TYPE = 'st',
+ /**
+ * Key is included without a value if the object is needed urgently due to startup, seeking or recovery after a buffer-empty event.
+ * The media SHOULD not be rendering when this request is made.
+ * This key MUST not be sent if it is FALSE.
+ */
+ STARTUP = 'su',
+ /**
+ * The highest bitrate rendition in the manifest or playlist that the client is allowed to play, given current codec,
+ * licensing and sizing constraints.
+ *
+ * Exposed as an Integer in kbps.
+ */
+ TOP_BITRATE = 'tb',
+ /**
+ * The version of this specification used for interpreting the defined key names and values.
+ * If this key is omitted, the client and server MUST interpret the values as being defined by version 1.
+ * Client SHOULD omit this field if the version is 1.
+ */
+ VERSION = 'v',
+
+ /**
+ * A GUID identifying the current request. Every request will automatically receive a new GUID automatically.
+ * Maximum length is 64 characters. It is RECOMMENDED to conform to the UUID specification.
+ *
+ * Note: This is NOT a part of the CMCD specification as is, but is an additional parameter in light with the work done in the SVA.
+ */
+ SVA_REQUEST_ID = 'org.svalabs-rid',
+ /**
+ * The 1-based index of the CMAF Track, of which the currently loading object is a part, in the sorted list of all
+ * tracks in the associated Aligned CMAF Switching Set.
+ * This list is sorted as follows:
+ * - In ascending order of the protocol's perceptual quality score (if available),
+ * - In ascending order of the Track's bandwidth,
+ * - In ascending order of the order as present in the stream Manifests.
+ *
+ * Note: This is NOT a part of the CMCD specification as is, but is an additional parameter in light with the work done in the SVA.
+ */
+ SVA_CURRENT_MANIFEST_INDEX = 'org.svalabs-cmi',
+ /**
+ * The number of CMAF Tracks, of which the currently loading object is a part, in the sorted list of all
+ * tracks in the associated Aligned CMAF Switching Set.
+ *
+ * Note: This is NOT a part of the CMCD specification as is, but is an additional parameter in light with the work done in the SVA.
+ */
+ SVA_PLAYABLE_MANIFEST_INDEX = 'org.svalabs-pmi',
+ /**
+ * The identifier of the Aligned CMAF Switching Set to which the currently loading object belongs. Its value must be unique
+ * across the current viewer session for all different Aligned CMAF Switching Sets and should (if available) be the identifier
+ * in the CMAF Manifest.
+ *
+ * Note: This is NOT a part of the CMCD specification as is, but is an additional parameter in light with the work done in the SVA.
+ */
+ SVA_TRACK_IDENTIFIER = 'org.svalabs-tid'
+}
+
+/**
+ * The Object Type as specified in Table 1.
+ */
+export enum CMCDObjectType {
+ /**
+ * Audio only
+ */
+ AUDIO = 'a', // note there is 'm' as an alternative
+ /**
+ * Video only
+ */
+ VIDEO = 'v',
+ /**
+ * Muxed audio and video
+ */
+ MUXED_AUDIO_VIDEO = 'av',
+ /**
+ * Init segment
+ */
+ INIT_SEGMENT = 'i',
+ /**
+ * Caption or subtitle
+ */
+ CAPTION_OR_SUBTITLE = 'c',
+ /**
+ * ISOBMFF timed text track
+ */
+ TIMED_TEXT_TRACK = 'tt',
+ /**
+ * Cryptographic key, license or certificate
+ */
+ KEY_LICENSE_OR_CERTIFICATE = 'k',
+ /**
+ * Other (not unknown)
+ */
+ OTHER = 'o'
+}
+
+/**
+ * The Streaming Format as specified in Table 1.
+ */
+export enum CMCDStreamingFormat {
+ /**
+ * MPEG-DASH streaming
+ */
+ MPEG_DASH = 'd',
+ /**
+ * HTTP Live Streaming (HLS)
+ */
+ HLS = 'h',
+ /**
+ * Microsoft Smooth Streaming
+ */
+ SMOOTH = 's',
+ /**
+ * Other (not unknown).
+ */
+ OTHER = 'o'
+}
+
+/**
+ * The Stream Type as specified in Table 1.
+ */
+export enum CMCDStreamType {
+ /**
+ * All segments are available e.g. VOD
+ */
+ STATIC = 'v',
+ /**
+ * Segments become available over time e.g. LIVE
+ */
+ DYNAMIC = 'l'
+}
diff --git a/cmcd/src/CMCDPayloadUtils.ts b/cmcd/src/CMCDPayloadUtils.ts
new file mode 100644
index 00000000..a66df287
--- /dev/null
+++ b/cmcd/src/CMCDPayloadUtils.ts
@@ -0,0 +1,91 @@
+import { CMCDHeaderName, CMCDPayload, CMCDReservedKey } from './CMCDPayload';
+
+/**
+ * Transforms the provided payload into a query parameter string. Strings will be escaped and placed between quotes, numbers
+ * will be passed as raw numbers, booleans will be shortened and tokens will be transformed as per the specification.
+ * The resulting list of parameters will be sorted ascending based on UTF-16 values of the keys.
+ * @param payload The payload.
+ * @returns A query parameter string containing all payload parameters as specified.
+ */
+export function transformToQueryParameters(payload: CMCDPayload): string {
+ const serialisedEntries: string[] = [];
+ for (const key in payload) {
+ if (Object.prototype.hasOwnProperty.call(payload, key)) {
+ const value = payload[key];
+ if (typeof value === 'boolean') {
+ if (value) {
+ serialisedEntries.push(key);
+ } else {
+ serialisedEntries.push(`${key}=false`);
+ }
+ } else if (typeof value === 'number') {
+ serialisedEntries.push(`${key}=${value}`);
+ } else {
+ serialisedEntries.push(
+ `${key}=${JSON.stringify(
+ encodeURIComponent(payload[key].replace(/\\/g, '\\\\').replace(/"/g, '\\"'))
+ )}`
+ );
+ }
+ }
+ }
+ serialisedEntries.sort();
+ return serialisedEntries.join(',');
+}
+
+/**
+ * Provides a map of all reserved keys to the header name.
+ * @param key The key for which you want to know the header name.
+ * @returns The header name in which the key should be sent.
+ */
+function mapKeyToHeaderMapping(key: string): CMCDHeaderName {
+ switch (key) {
+ case CMCDReservedKey.ENCODED_BITRATE:
+ case CMCDReservedKey.OBJECT_DURATION:
+ case CMCDReservedKey.TOP_BITRATE:
+ return CMCDHeaderName.CMCD_OBJECT;
+ case CMCDReservedKey.BUFFER_LENGTH:
+ case CMCDReservedKey.DEADLINE:
+ case CMCDReservedKey.MEASURED_THROUGHPUT:
+ case CMCDReservedKey.NEXT_OBJECT_REQUEST:
+ case CMCDReservedKey.NEXT_RANGE_REQUEST:
+ case CMCDReservedKey.OBJECT_TYPE:
+ case CMCDReservedKey.SVA_REQUEST_ID:
+ case CMCDReservedKey.SVA_TRACK_IDENTIFIER:
+ case CMCDReservedKey.SVA_PLAYABLE_MANIFEST_INDEX:
+ case CMCDReservedKey.SVA_CURRENT_MANIFEST_INDEX:
+ case CMCDReservedKey.STARTUP:
+ return CMCDHeaderName.CMCD_REQUEST;
+ case CMCDReservedKey.BUFFER_STARVATION:
+ case CMCDReservedKey.REQUESTED_MAXIMUM_THROUGHPUT:
+ return CMCDHeaderName.CMCD_STATUS;
+ case CMCDReservedKey.CONTENT_ID:
+ case CMCDReservedKey.PLAYBACK_RATE:
+ case CMCDReservedKey.STREAMING_FORMAT:
+ case CMCDReservedKey.SESSION_ID:
+ case CMCDReservedKey.STREAM_TYPE:
+ case CMCDReservedKey.VERSION:
+ default:
+ return CMCDHeaderName.CMCD_SESSION;
+ }
+}
+
+/**
+ * Returns a new {@link CMCDPayload} which contains all the keys from the provided payload but only if these keys
+ * should be sent with the provided header.
+ * @param payload The payload.
+ * @param header The header for which payload entries must be retained.
+ * @returns A payload containing only the key/value pairs for the provided header.
+ */
+export function extractKeysFor(payload: CMCDPayload, header: CMCDHeaderName): CMCDPayload {
+ const filteredPayload: CMCDPayload = {};
+ for (const key in payload) {
+ if (Object.prototype.hasOwnProperty.call(payload, key)) {
+ const headerForKey = mapKeyToHeaderMapping(key);
+ if (headerForKey === header) {
+ filteredPayload[key] = payload[key];
+ }
+ }
+ }
+ return filteredPayload;
+}
diff --git a/cmcd/src/Configuration.ts b/cmcd/src/Configuration.ts
new file mode 100644
index 00000000..dc77b068
--- /dev/null
+++ b/cmcd/src/Configuration.ts
@@ -0,0 +1,66 @@
+/**
+ * The transmission mode to be used.
+ */
+export enum TransmissionMode {
+ /**
+ * Transmit CMCD data as a custom HTTP request header.
+ *
+ * @remarks
+ *
+ * Usage of a custom header from a web browser user-agent will trigger a preflight OPTIONS request before
+ * each unique media object request. This will lead to an increased request rate against the server. As a result,
+ * for CMCD transmissions from web browser user-agents that require CORS-preflighting per URL, the preferred mode
+ * of use is query arguments.
+ */
+ HTTP_HEADER,
+ /**
+ * Transmit CMCD data as a HTTP query argument.
+ */
+ QUERY_ARGUMENT,
+ /**
+ * Transmit CMCD data as a JSON object independent of the HTTP object request.
+ */
+ JSON_OBJECT
+}
+
+/**
+ * The configuration object for the Common Media Client Data (CTA-5004) connector.
+ */
+export interface Configuration {
+ /**
+ * The data transmission mode as defined in section 2 of the specification.
+ * When no transmission mode is selected, {@link TransmissionMode.QUERY_ARGUMENT} will be used in order to avoid CORS preflight requests in browsers.
+ */
+ transmissionMode: TransmissionMode;
+
+ /**
+ * The target URI where client data is to be delivered in case the {@link Configuration.transmissionMode} is set
+ * to {@link TransmissionMode.JSON_OBJECT}.
+ */
+ jsonObjectTargetURI?: string;
+
+ /**
+ * The session ID parameter which should be passed as a CMCD value. If left empty, a UUIDv4 will be generated when applying the configuration.
+ */
+ sessionID?: string;
+
+ /**
+ * The content ID parameter which should be passed as a CMCD value. If left empty, no content ID will be sent.
+ */
+ contentID?: string;
+
+ /**
+ * A flag to indicate if request IDs should be sent or not.
+ * When set to a truthly value, a UUIDv4 will be sent as a request id (`rid`) with every request to allow for request tracing.
+ */
+ sendRequestID?: boolean;
+
+ /**
+ * An object containing custom keys which should be added to the generated CMCD parameters.
+ * Note custom keys MUST carry a hyphenated prefix to ensure that there will not be a namespace collision with future
+ * revisions to the specification. Clients SHOULD use a reverse-DNS syntax when defining their own prefix.
+ */
+ customKeys?: {
+ [key: string]: string | number | boolean;
+ };
+}
diff --git a/cmcd/src/PlayerUtils.ts b/cmcd/src/PlayerUtils.ts
new file mode 100644
index 00000000..be9fb21d
--- /dev/null
+++ b/cmcd/src/PlayerUtils.ts
@@ -0,0 +1,62 @@
+import { TypedSource } from 'theoplayer';
+import { CMCDStreamingFormat } from './CMCDPayload';
+
+function isM3U8SourceString(source: string): boolean {
+ return source.indexOf('m3u8') !== -1;
+}
+
+function isDASHSourceString(source: string): boolean {
+ return source.indexOf('mpd') !== -1;
+}
+
+function guessStreamingFormatFromURI(uri: string | undefined): CMCDStreamingFormat {
+ if (!uri) {
+ return CMCDStreamingFormat.OTHER;
+ }
+ if (isDASHSourceString(uri)) {
+ return CMCDStreamingFormat.MPEG_DASH;
+ }
+ if (isM3U8SourceString(uri)) {
+ return CMCDStreamingFormat.HLS;
+ }
+ return CMCDStreamingFormat.OTHER;
+}
+
+/**
+ * Returns the streaming format as observed in the provided {@link TypedSource}. If no {@link TypedSource.type} is provided,
+ * it will be inferred from the {@link TypedSource.src}, or the {@link CMCDStreamingFormat.OTHER} will be returned.
+ * @param source The source for which the {@link CMCDStreamingFormat} is to be inferred.
+ * @returns The streaming format for the provided source.
+ */
+export function getStreamingFormatFromTypedSource(source: TypedSource): CMCDStreamingFormat {
+ const type = source.type;
+ switch (type) {
+ case 'application/dash+xml':
+ return CMCDStreamingFormat.MPEG_DASH;
+ case 'application/vnd.apple.mpegurl':
+ case 'application/x-mpegurl':
+ return CMCDStreamingFormat.HLS;
+ default:
+ return guessStreamingFormatFromURI(source.src);
+ }
+}
+
+const BUFFER_GAP_OFFSET = 0.05;
+
+/**
+ * Returns the size of the buffer in seconds as of the provided time in the given ranges. Will jump gaps if small enough.
+ * @param currentTime The time as of which the buffer size is to be calculated.
+ * @param buffered The time range which is to be used containing ranges which are within the buffer.
+ * @returns The buffer size in seconds.
+ */
+export function calculateBufferSize(currentTime: number, buffered: TimeRanges): number {
+ let timeIndex = currentTime;
+ for (let bufferIndex = 0; bufferIndex < buffered.length; bufferIndex += 1) {
+ const rangeStart = buffered.start(bufferIndex);
+ const rangeEnd = buffered.end(bufferIndex);
+ if (timeIndex + BUFFER_GAP_OFFSET >= rangeStart && timeIndex - BUFFER_GAP_OFFSET <= rangeEnd) {
+ timeIndex = rangeEnd;
+ }
+ }
+ return timeIndex - currentTime;
+}
diff --git a/cmcd/src/RandomUtils.ts b/cmcd/src/RandomUtils.ts
new file mode 100644
index 00000000..492e69b5
--- /dev/null
+++ b/cmcd/src/RandomUtils.ts
@@ -0,0 +1,14 @@
+const randomGenerator: () => number = () =>
+ crypto ? crypto.getRandomValues(new Uint8Array(1))[0] : Math.random() * 255;
+
+/**
+ * Helper function to retrieve a random UUID.
+ * @returns A randomly generated UUIDv4.
+ */
+export function uuid(): string {
+ return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: string) => {
+ const i: number = Number(c);
+ // eslint-disable-next-line no-bitwise
+ return (i ^ (randomGenerator() & (15 >> (i / 4)))).toString(16);
+ });
+}
diff --git a/cmcd/src/TransmissionModeStrategies/HTTPHeaderTransmissionModeStrategy.ts b/cmcd/src/TransmissionModeStrategies/HTTPHeaderTransmissionModeStrategy.ts
new file mode 100644
index 00000000..149fa2d0
--- /dev/null
+++ b/cmcd/src/TransmissionModeStrategies/HTTPHeaderTransmissionModeStrategy.ts
@@ -0,0 +1,37 @@
+import { InterceptableRequest } from 'theoplayer';
+import { CMCDHeaderName, CMCDPayload } from '../CMCDPayload';
+import { extractKeysFor, transformToQueryParameters } from '../CMCDPayloadUtils';
+import { TransmissionModeStrategy } from './TransmissionModeStrategy';
+
+/**
+ * The transmission mode strategy to transmit CMCD data as HTTP Headers as specified in section 2.1 of CTA-5004.
+ * This strategy will add CMCD headers prefixed with `CMCD-` based on their expected level of variability:
+ * - CMCD-Request: keys whose values vary with each request.
+ * - CMCD-Object: keys whose values vary with the object being requested.
+ * - CMCD-Status: keys whose values do not vary with every request or object.
+ * - CMCD-Session: keys whose values are expected to be invariant over the life of the session.
+ * Note the addition of these headers will trigger CORS pre-flight requests in most web based environments.
+ */
+export class HTTPHeaderTransmissionModeStrategy implements TransmissionModeStrategy {
+ /**
+ * The method responsible to transmit the CMCD payload for the provided request.
+ * This strategy piggybacks on the provided request and will add the relevant CMCD headers by redirecting the original request.
+ * @param request The request for which the CMCD payload was generated.
+ * @param payload The payload which should be sent.
+ */
+ transmitPayload(request: InterceptableRequest, payload: CMCDPayload): void {
+ const headers = request.headers;
+
+ for (const headerName of Object.values(CMCDHeaderName)) {
+ const parameters = transformToQueryParameters(extractKeysFor(payload, headerName));
+ if (parameters) {
+ headers[headerName] = parameters;
+ }
+ }
+
+ request.redirect({
+ ...request,
+ headers
+ });
+ }
+}
diff --git a/cmcd/src/TransmissionModeStrategies/JSONObjectTransmissionModeStrategy.ts b/cmcd/src/TransmissionModeStrategies/JSONObjectTransmissionModeStrategy.ts
new file mode 100644
index 00000000..2bc9e2ea
--- /dev/null
+++ b/cmcd/src/TransmissionModeStrategies/JSONObjectTransmissionModeStrategy.ts
@@ -0,0 +1,30 @@
+import { InterceptableRequest } from 'theoplayer';
+import { CMCDPayload } from '../CMCDPayload';
+import { TransmissionModeStrategy } from './TransmissionModeStrategy';
+
+/**
+ * The transmission mode strategy to transmit CMCD data as a JSON object to an alternative endpoint as specified in section 2.3 of CTA-5004.
+ * This means the {@link CMCDPayload} will be sent as the body of an HTTP POST body as a plain JSON object for the configured URI.
+ */
+export class JSONObjectTransmissionModeStrategy implements TransmissionModeStrategy {
+ private readonly _uri: string;
+
+ /**
+ * Creates a new instance and ensures configuration of the provided endpoint.
+ * @param uri The endpoint to which any payload is to be sent.
+ */
+ constructor(uri: string) {
+ this._uri = uri;
+ }
+
+ /**
+ * The method responsible to transmit the CMCD payload for the provided request.
+ * This strategy will send out a beacon containing a JSON representation of the payload to the configured URI.
+ * @param request The request for which the CMCD payload was generated.
+ * @param payload The payload which should be sent.
+ */
+ transmitPayload(request: InterceptableRequest, payload: CMCDPayload): void {
+ const data = JSON.stringify(payload);
+ navigator.sendBeacon(this._uri, data);
+ }
+}
diff --git a/cmcd/src/TransmissionModeStrategies/QueryArgumentTransmissionModeStrategy.ts b/cmcd/src/TransmissionModeStrategies/QueryArgumentTransmissionModeStrategy.ts
new file mode 100644
index 00000000..7555d25d
--- /dev/null
+++ b/cmcd/src/TransmissionModeStrategies/QueryArgumentTransmissionModeStrategy.ts
@@ -0,0 +1,29 @@
+import { InterceptableRequest } from 'theoplayer';
+import { CMCDPayload } from '../CMCDPayload';
+import { transformToQueryParameters } from '../CMCDPayloadUtils';
+import { TransmissionModeStrategy } from './TransmissionModeStrategy';
+
+/**
+ * The transmission mode strategy to transmit CMCD data as query arguments as specified in section 2.2 of CTA-5004.
+ * This strategy will append a `CMCD` parameter containing the url encoded concatenation of all key value pairs.
+ */
+export class QueryArgumentTransmissionModeStrategy implements TransmissionModeStrategy {
+ /**
+ * The method responsible to transmit the CMCD payload for the provided request.
+ * This strategy piggybacks on the provided request and will add a `CMCD` parameter to the query string by redirecting
+ * the original request.
+ * @param request The request for which the CMCD payload was generated.
+ * @param payload The payload which should be sent.
+ */
+ transmitPayload(request: InterceptableRequest, payload: CMCDPayload): void {
+ const url = new URL(request.url);
+ const parameters = transformToQueryParameters(payload);
+ if (parameters) {
+ url.searchParams.append('CMCD', parameters);
+ request.redirect({
+ ...request,
+ url: url.href
+ });
+ }
+ }
+}
diff --git a/cmcd/src/TransmissionModeStrategies/TransmissionModeStrategy.ts b/cmcd/src/TransmissionModeStrategies/TransmissionModeStrategy.ts
new file mode 100644
index 00000000..b5894a36
--- /dev/null
+++ b/cmcd/src/TransmissionModeStrategies/TransmissionModeStrategy.ts
@@ -0,0 +1,41 @@
+import { InterceptableRequest } from 'theoplayer';
+import { CMCDPayload } from '../CMCDPayload';
+import { Configuration, TransmissionMode } from '../Configuration';
+import { HTTPHeaderTransmissionModeStrategy } from './HTTPHeaderTransmissionModeStrategy';
+import { JSONObjectTransmissionModeStrategy } from './JSONObjectTransmissionModeStrategy';
+import { QueryArgumentTransmissionModeStrategy } from './QueryArgumentTransmissionModeStrategy';
+
+/**
+ * The main interface for all transmission modes.
+ */
+export interface TransmissionModeStrategy {
+ /**
+ * The method responsible to transmit the CMCD payload for the provided request. The strategy can either piggyback
+ * on the provided request, or can issue a parallel request.
+ * @param request The request for which the CMCD payload was generated.
+ * @param payload The payload which should be sent.
+ */
+ transmitPayload(request: InterceptableRequest, payload: CMCDPayload): void;
+}
+
+/**
+ * Factory function to create the appropriate {@link TransmissionModeStrategy}.
+ * @param configuration The configuration of the connector containing information on the proper instantiation of the transmission mode.
+ * @throws Error when {@link TransmissionMode.JSON_OBJECT} is to be used, but no {@link Configuration.jsonObjectTargetURI} is provided.
+ */
+export function createTransmissionModeStrategyFor(configuration: Configuration): TransmissionModeStrategy {
+ switch (configuration.transmissionMode) {
+ case TransmissionMode.HTTP_HEADER:
+ return new HTTPHeaderTransmissionModeStrategy();
+ case TransmissionMode.JSON_OBJECT:
+ if (!configuration.jsonObjectTargetURI) {
+ throw new Error(
+ 'When using the `TransmissionMode.JSON_OBJECT` transmission mode, a `jsonObjectTargetURI` must be provided.'
+ );
+ }
+ return new JSONObjectTransmissionModeStrategy(configuration.jsonObjectTargetURI);
+ case TransmissionMode.QUERY_ARGUMENT:
+ default:
+ return new QueryArgumentTransmissionModeStrategy();
+ }
+}
diff --git a/cmcd/src/index.ts b/cmcd/src/index.ts
new file mode 100644
index 00000000..3faa1eef
--- /dev/null
+++ b/cmcd/src/index.ts
@@ -0,0 +1,15 @@
+import { CMCDObjectType, CMCDPayload, CMCDReservedKey, CMCDStreamingFormat, CMCDStreamType } from './CMCDPayload';
+import { TransmissionMode } from './Configuration';
+import { CMCDConnector, CMCDPayloadProcessor, createCMCDConnector } from './CMCDConnector';
+
+export {
+ CMCDConnector,
+ createCMCDConnector,
+ CMCDPayloadProcessor,
+ TransmissionMode,
+ CMCDPayload,
+ CMCDReservedKey,
+ CMCDStreamingFormat,
+ CMCDObjectType,
+ CMCDStreamType
+};
diff --git a/cmcd/test/pages/main_esm.html b/cmcd/test/pages/main_esm.html
new file mode 100644
index 00000000..192c0d94
--- /dev/null
+++ b/cmcd/test/pages/main_esm.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+