Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

### Added

- Added basic support for CMCD event mode reporting of DRM and ad events.

### Fixed

- Fixed an issue on Android where `GoogleImaConfiguration.sessionId` was not properly passed.
Expand Down
26 changes: 26 additions & 0 deletions android/src/main/java/com/theoplayer/PlayerConfigAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import com.theoplayer.android.api.THEOplayerConfig
import com.theoplayer.android.api.THEOplayerGlobal
import com.theoplayer.android.api.cast.CastStrategy
import com.theoplayer.android.api.cast.CastConfiguration
import com.theoplayer.android.api.cmcd.CMCDConfiguration
import com.theoplayer.android.api.cmcd.CMCDEndpointConfiguration
import com.theoplayer.android.api.pip.PipConfiguration
import com.theoplayer.android.api.player.NetworkConfiguration
import com.theoplayer.android.api.theolive.THEOLiveConfig
Expand Down Expand Up @@ -45,6 +47,11 @@ private const val PROP_THEOLIVE_DISCOVERY_URL = "discoveryUrl"
private const val PROP_MULTIMEDIA_TUNNELING_ENABLED = "tunnelingEnabled"
private const val PROP_DEBUG_LOGS_ENABLED = "debugLogsEnabled"
private const val PROP_SYSTEM_CAPTION_STYLE = "useSystemCaptionStyle"
private const val PROP_CMCD = "cmcd"
private const val PROP_CMCD_EXTERNAL_SESSION_ID = "externalSessionId"
private const val PROP_CMCD_USER_ID = "userId"
private const val PROP_CMCD_EVENT_ENDPOINTS = "eventEndpoints"
private const val PROP_CMCD_ENDPOINT_URL = "url"


class PlayerConfigAdapter(private val configProps: ReadableMap?) {
Expand Down Expand Up @@ -88,6 +95,9 @@ class PlayerConfigAdapter(private val configProps: ReadableMap?) {
if (hasKey(PROP_SYSTEM_CAPTION_STYLE)) {
useSystemCaptionStyle(getBoolean(PROP_SYSTEM_CAPTION_STYLE))
}
if (hasKey(PROP_CMCD)) {
cmcd(cmcdConfig())
}
}
}.build()
}
Expand Down Expand Up @@ -241,6 +251,22 @@ class PlayerConfigAdapter(private val configProps: ReadableMap?) {
return MediaSessionConfigAdapter.fromProps(configProps?.getMap(PROP_MEDIA_CONTROL))
}

private fun cmcdConfig(): CMCDConfiguration {
val config = configProps?.getMap(PROP_CMCD)
val endpoints = config?.getArray(PROP_CMCD_EVENT_ENDPOINTS)?.let { arr ->
(0 until arr.size()).mapNotNull { i ->
arr.getMap(i)?.getString(PROP_CMCD_ENDPOINT_URL)?.let { url ->
CMCDEndpointConfiguration(url = url)
}
}
}
return CMCDConfiguration(
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
externalSessionId = config?.getString(PROP_CMCD_EXTERNAL_SESSION_ID),
userId = config?.getString(PROP_CMCD_USER_ID),
eventEndpoints = endpoints
)
}

private fun theoLiveConfig (): THEOLiveConfig {
val config = configProps?.getMap(PROP_THEOLIVE_CONFIG)
return THEOLiveConfig.Builder(
Expand Down
39 changes: 38 additions & 1 deletion android/src/main/java/com/theoplayer/source/SourceAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import com.theoplayer.android.api.player.track.texttrack.TextTrackKind
import com.theoplayer.android.api.source.metadata.ChromecastMetadataImage
import com.theoplayer.BuildConfig
import com.theoplayer.android.api.ads.theoads.TheoAdsLayoutOverride
import com.theoplayer.android.api.cmcd.CMCDEndpointConfiguration
import com.theoplayer.android.api.cmcd.CMCDSourceConfiguration
import com.theoplayer.android.api.cmcd.CMCDTransmissionMode
import com.theoplayer.android.api.error.ErrorCode
import com.theoplayer.android.api.source.AdIntegration
Expand Down Expand Up @@ -94,6 +96,11 @@ private const val TYPE_MILLICAST = "millicast"

private const val PROP_CMCD = "cmcd"
private const val CMCD_TRANSMISSION_MODE = "transmissionMode"
private const val CMCD_SESSION_ID = "sessionID"
private const val CMCD_EXTERNAL_SESSION_ID = "externalSessionId"
private const val CMCD_USER_ID = "userId"
private const val CMCD_EVENT_ENDPOINTS = "eventEndpoints"
private const val CMCD_ENDPOINT_URL = "url"

class SourceAdapter {
private val gson = Gson()
Expand Down Expand Up @@ -175,6 +182,11 @@ class SourceAdapter {
if (metadataDescription != null) {
builder.metadata(metadataDescription)
}
if (jsonSourceObject.has(PROP_CMCD)) {
parseCmcdSourceConfiguration(jsonSourceObject.getJSONObject(PROP_CMCD))?.let {
builder.cmcd(it)
}
}
return builder.build()
} catch (e: JSONException) {
e.printStackTrace()
Expand Down Expand Up @@ -464,7 +476,10 @@ class SourceAdapter {
return BridgeUtils.fromJSONObjectToBridge(JSONObject(gson.toJson(typedSource)))
}

private fun parseCmcdTransmissionMode(cmcdConfiguration : JSONObject) : CMCDTransmissionMode {
private fun parseCmcdTransmissionMode(cmcdConfiguration : JSONObject) : CMCDTransmissionMode? {
if (!cmcdConfiguration.has(CMCD_TRANSMISSION_MODE)) {
return null
}
try {
val transmissionMode = cmcdConfiguration.optInt(CMCD_TRANSMISSION_MODE)
if (transmissionMode == CmcdTransmissionMode.QUERY_ARGUMENT.ordinal) {
Expand All @@ -476,4 +491,26 @@ class SourceAdapter {
return CMCDTransmissionMode.HTTP_HEADER
}
}

private fun parseCmcdSourceConfiguration(cmcdJson: JSONObject): CMCDSourceConfiguration? {
val sessionId = cmcdJson.optString(CMCD_SESSION_ID, null)
val externalSessionId = cmcdJson.optString(CMCD_EXTERNAL_SESSION_ID, null)
val userId = cmcdJson.optString(CMCD_USER_ID, null)
val endpoints = cmcdJson.optJSONArray(CMCD_EVENT_ENDPOINTS)?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.optString(CMCD_ENDPOINT_URL)?.let { url ->
CMCDEndpointConfiguration(url = url)
}
}
}
if (sessionId == null && externalSessionId == null && userId == null && endpoints == null) {
return null
}
return CMCDSourceConfiguration(
sessionId = sessionId,
externalSessionId = externalSessionId,
userId = userId,
eventEndpoints = endpoints
)
}
}
63 changes: 56 additions & 7 deletions doc/cmcd.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
# Getting started with CMCD on React Native

Media player clients can transmit useful information to Content Delivery Networks (CDNs) with each object request.
This implementation supports Common Media Client Data (CMCD) as defined in
[CTA-5004](https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf), published in September 2020.
This implementation is planned to fully support Common Media Client Data (CMCD) as defined in
[CTA-5004-B](https://cta-wave.github.io/Resources/common-media-client-data--cta-5004-b.html), published in April 2026.

## Usage
CMCD supports two modes of transmission:

To enable CMCD, developers can set the `cmcd` parameter inside a SourceDescription.
- **Request mode**: CMCD data is sent as HTTP headers or query parameters on manifest and media segment requests.
- **Event mode** (available since v11.4.0): CMCD events are POSTed to configured HTTP endpoints.

## Request mode

To enable CMCD request mode, developers need to explicitly set the `transmissionMode` parameter inside a SourceDescription.

```tsx
const source = {
Expand Down Expand Up @@ -42,11 +47,55 @@ player.source = source;

On Web, there are additional configuration options. For more details, visit the API references.

## Remarks
### Remarks

- Note that CMCD is only supported on iOS 18.0+.
- Note that CMCD is only supported with the [Media3 integration](./media3.md) on Android.
- Note that CMCD request mode is only supported on iOS 18.0+.
- Note that using a custom HTTP 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.

## Event mode

Event mode allows posting CMCD events to configured HTTP endpoints.

:::info
Event mode is supported starting from version 11.4.0.
:::

### Player-level configuration

```javascript
const player = new THEOplayer.Player(element, {
cmcd: {
externalSessionId: 'YOUR-EXTERNAL-SESSION-ID', // optional
userId: 'YOUR-USER-ID', // optional
eventEndpoints: [{ url: 'https://example.com/cmcd-event-endpoint' }],
},
});
```

### Source-level configuration

```javascript
player.source = {
sources: [ /* ... */ ],
/* ... */
cmcd: {
externalSessionId: 'YOUR-EXTERNAL-SESSION-ID', // optional
sessionID: 'YOUR-SESSION-ID', // optional
userId: 'YOUR-USER-ID', // optional
eventEndpoints: [{ url: 'https://example.com/cmcd-event-other-endpoint' }],
},
};
```

### Merging behavior

- Source-level values take precedence for `externalSessionId` and `userId`.
- `eventEndpoints` from both levels are **merged** (both player and source endpoints receive events).

:::warning
Event mode reporting currently only supports DRM and ad events.
:::

37 changes: 31 additions & 6 deletions ios/THEOplayerRCTSourceDescriptionBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ let SD_PROP_INITIALIZATION_DELAY: String = "initializationDelay"
let SD_PROP_HLS_DATE_RANGE: String = "hlsDateRange"
let SD_PROP_BREAK_MANIFEST_URL: String = "breakManifestUrl"
let SD_PROP_CMCD: String = "cmcd"
let SD_PROP_TRANSMISSION_MODE: String = "transmissionMode"
let SD_PROP_QUERY_PARAMETERS: String = "queryParameters"

let EXTENSION_HLS: String = ".m3u8"
Expand Down Expand Up @@ -167,25 +168,49 @@ class THEOplayerRCTSourceDescriptionBuilder {
}

// 6. configure CMCD
let cmcd = sourceData[SD_PROP_CMCD] as? [String:Any]
if cmcd != nil {
typedSources.forEach { typedSource in
typedSource.cmcd = true;
var cmcdSourceConfig: CMCDSourceConfiguration? = nil
if let cmcd = sourceData[SD_PROP_CMCD] as? [String:Any] {
let requestModeEnabled = cmcd[SD_PROP_TRANSMISSION_MODE] != nil
if requestModeEnabled {
typedSources.forEach { typedSource in
typedSource.cmcd = true
}
}
cmcdSourceConfig = THEOplayerRCTSourceDescriptionBuilder.buildCmcdSourceConfiguration(cmcd)
}

// 7. construct the SourceDescription
let sourceDescription = SourceDescription(sources: typedSources,
textTracks: textTrackDescriptions,
ads: adsDescriptions,
poster: poster,
metadata: metadataDescription)

metadata: metadataDescription,
cmcdConfiguration: cmcdSourceConfig)

return (sourceDescription, metadataAndChapterTrackDescriptions)
}

// MARK: Private build methods

private static func buildCmcdSourceConfiguration(_ cmcdData: [String:Any]) -> CMCDSourceConfiguration? {
let sessionId = cmcdData["sessionID"] as? String
let externalSessionId = cmcdData["externalSessionId"] as? String
let userId = cmcdData["userId"] as? String
let endpoints: [CMCDEndpointConfiguration]? = (cmcdData["eventEndpoints"] as? [[String: Any]])?.compactMap { dict in
guard let url = dict["url"] as? String else { return nil }
return CMCDEndpointConfiguration(url: url)
}
if sessionId == nil && externalSessionId == nil && userId == nil && endpoints == nil {
return nil
}
return CMCDSourceConfiguration(
sessionId: sessionId,
externalSessionId: externalSessionId,
userId: userId,
eventEndpoints: endpoints
)
}

/**
Creates a THEOplayer TypedSource. This requires a source property for non SSAI strreams (either as a string or as an object contiaining a src property). For SSAI streams the TypeSource can be created from the ssai property.
- returns: a THEOplayer TypedSource. In case of SSAI we support GoogleDAITypedSource with GoogleDAIVodConfiguration or GoogleDAILiveConfiguration
Expand Down
3 changes: 3 additions & 0 deletions ios/THEOplayerRCTView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public class THEOplayerRCTView: UIView {
var castConfig = CastConfig()
var uiConfig = UIConfig()
var theoliveConfig = THEOliveConfig()
var cmcdConfig = CmcdConfig()

public var bypassDropInstanceOnReactLifecycle = false // controls superView detaching behaviour

Expand Down Expand Up @@ -232,6 +233,7 @@ public class THEOplayerRCTView: UIView {
config.hlsDateRange = self.hlsDateRange
config.license = self.license
config.licenseUrl = self.licenseUrl
config.cmcd = self.playerCmcdConfiguration()
self.player = THEOplayer(configuration: config.build())

self.initAdsIntegration()
Expand Down Expand Up @@ -322,6 +324,7 @@ public class THEOplayerRCTView: UIView {
self.parseUIConfig(configDict: configDict)
self.parseMediaControlConfig(configDict: configDict)
self.parseTHEOliveConfig(configDict: configDict)
self.parseCmcdConfig(configDict: configDict)
if DEBUG_VIEW { PrintUtils.printLog(logText: "[NATIVE] config prop updated.") }

// Given the bridged config, create the initial THEOplayer instance
Expand Down
36 changes: 36 additions & 0 deletions ios/cmcd/THEOplayerRCTView+CmcdConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// THEOplayerRCTView+CmcdConfig.swift

import Foundation
import THEOplayerSDK

struct CmcdConfig {
var externalSessionId: String?
var userId: String?
var eventEndpoints: [CMCDEndpointConfiguration]?
}

extension THEOplayerRCTView {

func parseCmcdConfig(configDict: NSDictionary) {
if let cmcdDict = configDict["cmcd"] as? NSDictionary {
self.cmcdConfig.externalSessionId = cmcdDict["externalSessionId"] as? String
self.cmcdConfig.userId = cmcdDict["userId"] as? String
self.cmcdConfig.eventEndpoints = (cmcdDict["eventEndpoints"] as? [[String: Any]])?.compactMap { dict -> CMCDEndpointConfiguration? in
guard let url = dict["url"] as? String else { return nil }
return CMCDEndpointConfiguration(url: url)
}
}
}

func playerCmcdConfiguration() -> CMCDConfiguration? {
let config = self.cmcdConfig
if config.externalSessionId == nil && config.userId == nil && config.eventEndpoints == nil {
return nil
}
return CMCDConfiguration(
externalSessionId: config.externalSessionId,
userId: config.userId,
eventEndpoints: config.eventEndpoints
)
}
}
2 changes: 1 addition & 1 deletion react-native-theoplayer.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Pod::Spec.new do |s|
s.platforms = { :ios => "15.0", :tvos => "15.0" }
s.source = { :git => "https://www.theoplayer.com/.git", :tag => "#{s.version}" }

s.source_files = 'ios/*.{h,m,swift}', 'ios/ads/*.swift', 'ios/casting/*.swift', 'ios/contentprotection/*.swift', 'ios/pip/*.swift', 'ios/backgroundAudio/*.swift', 'ios/cache/*.swift', 'ios/sideloadedMetadata/*.swift', 'ios/eventBroadcasting/*.swift' , 'ios/ui/*.swift', 'ios/presentationMode/*.swift', 'ios/viewController/*.swift', 'ios/THEOlive/*.swift', 'ios/THEOads/*.swift', 'ios/millicast/*.swift'
s.source_files = 'ios/*.{h,m,swift}', 'ios/ads/*.swift', 'ios/casting/*.swift', 'ios/contentprotection/*.swift', 'ios/pip/*.swift', 'ios/backgroundAudio/*.swift', 'ios/cache/*.swift', 'ios/sideloadedMetadata/*.swift', 'ios/eventBroadcasting/*.swift' , 'ios/ui/*.swift', 'ios/presentationMode/*.swift', 'ios/viewController/*.swift', 'ios/THEOlive/*.swift', 'ios/THEOads/*.swift', 'ios/millicast/*.swift', 'ios/cmcd/*.swift'
s.resources = ['ios/*.css']

# ReactNative Dependency
Expand Down
1 change: 1 addition & 0 deletions src/api/barrel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './backgroundAudio/barrel';
export * from './broadcast/barrel';
export * from './cache/barrel';
export * from './cast/barrel';
export * from './cmcd/barrel';
export * from './pip/barrel';
export * from './config/barrel';
export * from './error/barrel';
Expand Down
Loading
Loading