diff --git a/android/src/main/java/com/brentvatne/common/api/CMCDProps.kt b/android/src/main/java/com/brentvatne/common/api/CMCDProps.kt new file mode 100644 index 00000000..76bcefa1 --- /dev/null +++ b/android/src/main/java/com/brentvatne/common/api/CMCDProps.kt @@ -0,0 +1,51 @@ +package com.brentvatne.common.api + +import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetInt +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType + +data class CMCDProps( + val cmcdObject: List> = emptyList(), + val cmcdRequest: List> = emptyList(), + val cmcdSession: List> = emptyList(), + val cmcdStatus: List> = emptyList(), + val mode: Int = 1 +) { + companion object { + private const val PROP_CMCD_OBJECT = "object" + private const val PROP_CMCD_REQUEST = "request" + private const val PROP_CMCD_SESSION = "session" + private const val PROP_CMCD_STATUS = "status" + private const val PROP_CMCD_MODE = "mode" + + @JvmStatic + fun parse(src: ReadableMap?): CMCDProps? { + if (src == null) return null + + return CMCDProps( + cmcdObject = parseKeyValuePairs(src.getArray(PROP_CMCD_OBJECT)), + cmcdRequest = parseKeyValuePairs(src.getArray(PROP_CMCD_REQUEST)), + cmcdSession = parseKeyValuePairs(src.getArray(PROP_CMCD_SESSION)), + cmcdStatus = parseKeyValuePairs(src.getArray(PROP_CMCD_STATUS)), + mode = safeGetInt(src, PROP_CMCD_MODE, 1) + ) + } + + private fun parseKeyValuePairs(array: ReadableArray?): List> { + if (array == null) return emptyList() + + return (0 until array.size()).mapNotNull { i -> + val item = array.getMap(i) + val key = item?.getString("key") + val value = when (item?.getType("value")) { + ReadableType.Number -> item.getDouble("value") + ReadableType.String -> item.getString("value") + else -> null + } + + if (key != null && value != null) Pair(key, value) else null + } + } + } +} diff --git a/android/src/main/java/com/brentvatne/common/api/Source.kt b/android/src/main/java/com/brentvatne/common/api/Source.kt index ba4f8496..b9ea8bf7 100644 --- a/android/src/main/java/com/brentvatne/common/api/Source.kt +++ b/android/src/main/java/com/brentvatne/common/api/Source.kt @@ -57,6 +57,11 @@ class Source { */ var textTracksAllowChunklessPreparation: Boolean = false + /** + * CMCD properties linked to the source + */ + var cmcdProps: CMCDProps? = null + override fun hashCode(): Int = Objects.hash(uriString, uri, startPositionMs, cropStartMs, cropEndMs, extension, metadata, headers) /** return true if this and src are equals */ @@ -68,7 +73,8 @@ class Source { cropEndMs == other.cropEndMs && startPositionMs == other.startPositionMs && extension == other.extension && - drmProps == other.drmProps + drmProps == other.drmProps && + cmcdProps == other.cmcdProps ) } @@ -131,6 +137,7 @@ class Source { private const val PROP_SRC_METADATA = "metadata" private const val PROP_SRC_HEADERS = "requestHeaders" private const val PROP_SRC_DRM = "drm" + private const val PROP_SRC_CMCD = "cmcd" private const val PROP_SRC_TEXT_TRACKS_ALLOW_CHUNKLESS_PREPARATION = "textTracksAllowChunklessPreparation" @SuppressLint("DiscouragedApi") @@ -189,6 +196,7 @@ class Source { source.cropEndMs = safeGetInt(src, PROP_SRC_CROP_END, -1) source.extension = safeGetString(src, PROP_SRC_TYPE, null) source.drmProps = parse(safeGetMap(src, PROP_SRC_DRM)) + source.cmcdProps = CMCDProps.parse(safeGetMap(src, PROP_SRC_CMCD)) source.textTracksAllowChunklessPreparation = safeGetBool(src, PROP_SRC_TEXT_TRACKS_ALLOW_CHUNKLESS_PREPARATION, true) val propSrcHeadersArray = safeGetArray(src, PROP_SRC_HEADERS) diff --git a/android/src/main/java/com/brentvatne/exoplayer/CMCDConfig.kt b/android/src/main/java/com/brentvatne/exoplayer/CMCDConfig.kt new file mode 100644 index 00000000..60d9d598 --- /dev/null +++ b/android/src/main/java/com/brentvatne/exoplayer/CMCDConfig.kt @@ -0,0 +1,41 @@ +package com.brentvatne.exoplayer + +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.upstream.CmcdConfiguration +import com.brentvatne.common.api.CMCDProps +import com.google.common.collect.ImmutableListMultimap + +class CMCDConfig(private val props: CMCDProps) { + fun toCmcdConfigurationFactory(): CmcdConfiguration.Factory = CmcdConfiguration.Factory(::createCmcdConfiguration) + + private fun createCmcdConfiguration(mediaItem: MediaItem): CmcdConfiguration = + CmcdConfiguration( + java.util.UUID.randomUUID().toString(), + mediaItem.mediaId, + object : CmcdConfiguration.RequestConfig { + override fun getCustomData(): ImmutableListMultimap = buildCustomData() + }, + props.mode + ) + + private fun buildCustomData(): ImmutableListMultimap = + ImmutableListMultimap.builder().apply { + addFormattedData(this, CmcdConfiguration.KEY_CMCD_OBJECT, props.cmcdObject) + addFormattedData(this, CmcdConfiguration.KEY_CMCD_REQUEST, props.cmcdRequest) + addFormattedData(this, CmcdConfiguration.KEY_CMCD_SESSION, props.cmcdSession) + addFormattedData(this, CmcdConfiguration.KEY_CMCD_STATUS, props.cmcdStatus) + }.build() + + private fun addFormattedData(builder: ImmutableListMultimap.Builder, key: String, dataList: List>) { + dataList.forEach { (dataKey, dataValue) -> + builder.put(key, formatKeyValue(dataKey, dataValue)) + } + } + + private fun formatKeyValue(key: String, value: Any): String = + when (value) { + is String -> "$key=\"$value\"" + is Number -> "$key=$value" + else -> throw IllegalArgumentException("Unsupported value type: ${value::class.java}") + } +} diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index a3cee5e5..bb527903 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -96,6 +96,7 @@ import androidx.media3.exoplayer.trackselection.MappingTrackSelector; import androidx.media3.exoplayer.trackselection.TrackSelection; import androidx.media3.exoplayer.trackselection.TrackSelectionArray; import androidx.media3.exoplayer.upstream.BandwidthMeter; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.exoplayer.upstream.DefaultAllocator; import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter; import androidx.media3.exoplayer.util.EventLogger; @@ -269,6 +270,12 @@ public class ReactExoplayerView extends FrameLayout implements private String instanceId = String.valueOf(UUID.randomUUID()); + private CmcdConfiguration.Factory cmcdConfigurationFactory; + + public void setCmcdConfigurationFactory(CmcdConfiguration.Factory factory) { + this.cmcdConfigurationFactory = factory; + } + private void updateProgress() { if (player != null) { if (playerControlView != null && isPlayingAd() && controls) { @@ -1103,6 +1110,12 @@ public class ReactExoplayerView extends FrameLayout implements } } + if (cmcdConfigurationFactory != null) { + mediaSourceFactory = mediaSourceFactory.setCmcdConfigurationFactory( + cmcdConfigurationFactory::createCmcdConfiguration + ); + } + MediaItem mediaItem = mediaItemBuilder.setStreamKeys(streamKeys).build(); MediaSource mediaSource = mediaSourceFactory .setDrmSessionManagerProvider(drmProvider) @@ -1800,6 +1813,14 @@ public class ReactExoplayerView extends FrameLayout implements DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter, source.getHeaders()); + if (source.getCmcdProps() != null) { + CMCDConfig cmcdConfig = new CMCDConfig(source.getCmcdProps()); + CmcdConfiguration.Factory factory = cmcdConfig.toCmcdConfigurationFactory(); + this.setCmcdConfigurationFactory(factory); + } else { + this.setCmcdConfigurationFactory(null); + } + if (!isSourceEqual) { reloadSource(); } diff --git a/docs/pages/component/props.mdx b/docs/pages/component/props.mdx index 30cc4b7b..e3286596 100644 --- a/docs/pages/component/props.mdx +++ b/docs/pages/component/props.mdx @@ -1000,3 +1000,68 @@ Adjust the volume. - **1.0 (default)** - Play at full volume - **0.0** - Mute the audio - **Other values** - Reduce volume + +### `cmcd` + + + +Configure CMCD (Common Media Client Data) parameters. CMCD is a standard for conveying client-side metrics and capabilities to servers, which can help improve streaming quality and performance. + +For detailed information about CMCD, please refer to the [CTA-5004 Final Specification](https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf). + +- **false (default)** - Don't use CMCD +- **true** - Use default CMCD configuration +- **object** - Use custom CMCD configuration + +When providing an object, you can configure the following properties: + +| Property | Type | Description | +|----------|-------------------------|----------------------------------------------------| +| `mode` | `CmcdMode` | The mode for sending CMCD data | +| `request` | `CmcdData` | Custom key-value pairs for the request object | +| `session` | `CmcdData` | Custom key-value pairs for the session object | +| `object` | `CmcdData` | Custom key-value pairs for the object metadata | +| `status` | `CmcdData` | Custom key-value pairs for the status information | + +Note: The `mode` property defaults to `CmcdMode.MODE_QUERY_PARAMETER` if not specified. + +#### `CmcdMode` +CmcdMode is an enum that defines how CMCD data should be sent: +- `CmcdMode.MODE_REQUEST_HEADER` (0) - Send CMCD data in the HTTP request headers. +- `CmcdMode.MODE_QUERY_PARAMETER` (1) - Send CMCD data as query parameters in the URL. + +#### `CmcdData` +CmcdData is a type representing custom key-value pairs for CMCD data. It's defined as: + +```typescript +type CmcdData = Record<`${string}-${string}`, string | number>; +``` + +Custom key names MUST include a hyphenated prefix to prevent namespace collisions. It's recommended to use a reverse-DNS syntax for custom prefixes. + +Example: + +```javascript +