Skip to content

Commit

Permalink
feat(android): Support Common Media Client Data (CMCD) (#4034)
Browse files Browse the repository at this point in the history
* feat(VideoNativeComponent.ts): add support for cmcd configuration in VideoSrc type to enable cmcd feature on android
feat(video.ts): introduce CmcdMode enum and CmcdConfiguration type to define cmcd configuration options

* feat(Video.tsx): add support for CMCD configuration in Video component to handle Content Management and Delivery (CMCD) headers for Android platform.

* feat(CMCDProps.kt): add CMCDProps class to handle CMCD related properties and parsing logic for React Native module

* feat(CMCDConfig.kt): add CMCDConfig class to handle CMCD configuration for ExoPlayer with support for custom data and configuration options.

* feat(ReactExoplayerViewManager.java): add support for CMCD configuration in ReactExoplayerViewManager to enable Content Management and Control Data (CMCD) for better video playback optimization.

* feat(ReactExoplayerView.java): add support for setting CmcdConfiguration.Factory to customize CMCD configuration for media playback

* feat(Source.kt): add support for CMCD properties linked to the source to enhance functionality and data handling

* docs(props.mdx): add documentation for configuring CMCD parameters in the component, including usage examples and default values

* refactor(ReactExoplayerViewManager.java): remove unused PROP_CMCD and prevCmcdConfig variables to clean up code and improve readability

* refactor(Video.tsx): simplify cmcd configuration logic for better readability and maintainability

* docs(props.mdx): improve props documentation for clarity and consistency
feat(props.mdx): add definitions for CmcdMode enum and CmcdData type to enhance understanding of CMCD data structure and usage

* refactor(CMCDProps.kt): refactor CMCDProps class to data class for improved readability and immutability
-  update CMCDProps class to use List instead of Array for properties

* refactor(Video.tsx): refactor createCmcdHeader function to improve code readability and reduce duplication

* fix(CMCDProps.kt): remove import statement for CmcdConfiguration

* feat(ReactExoplayerView.java): add support for CMCD configuration in ReactExoplayerView component
feat(ReactExoplayerViewManager.java): remove redundant CMCD configuration logic from ReactExoplayerViewManager to simplify code and improve maintainability

* fix(Video.tsx): merge _cmcd memo into src memo for optimization
  • Loading branch information
uncoolclub authored Aug 22, 2024
1 parent 65faba3 commit ca795f2
Show file tree
Hide file tree
Showing 8 changed files with 249 additions and 3 deletions.
51 changes: 51 additions & 0 deletions android/src/main/java/com/brentvatne/common/api/CMCDProps.kt
Original file line number Diff line number Diff line change
@@ -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<Pair<String, Any>> = emptyList(),
val cmcdRequest: List<Pair<String, Any>> = emptyList(),
val cmcdSession: List<Pair<String, Any>> = emptyList(),
val cmcdStatus: List<Pair<String, Any>> = 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<Pair<String, Any>> {
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
}
}
}
}
10 changes: 9 additions & 1 deletion android/src/main/java/com/brentvatne/common/api/Source.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -68,7 +73,8 @@ class Source {
cropEndMs == other.cropEndMs &&
startPositionMs == other.startPositionMs &&
extension == other.extension &&
drmProps == other.drmProps
drmProps == other.drmProps &&
cmcdProps == other.cmcdProps
)
}

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions android/src/main/java/com/brentvatne/exoplayer/CMCDConfig.kt
Original file line number Diff line number Diff line change
@@ -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<String, String> = buildCustomData()
},
props.mode
)

private fun buildCustomData(): ImmutableListMultimap<String, String> =
ImmutableListMultimap.builder<String, String>().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<String, String>, key: String, dataList: List<Pair<String, Any>>) {
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}")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1103,6 +1110,12 @@ private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessi
}
}

if (cmcdConfigurationFactory != null) {
mediaSourceFactory = mediaSourceFactory.setCmcdConfigurationFactory(
cmcdConfigurationFactory::createCmcdConfiguration
);
}

MediaItem mediaItem = mediaItemBuilder.setStreamKeys(streamKeys).build();
MediaSource mediaSource = mediaSourceFactory
.setDrmSessionManagerProvider(drmProvider)
Expand Down Expand Up @@ -1800,6 +1813,14 @@ public void setSrc(Source source) {
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();
}
Expand Down
65 changes: 65 additions & 0 deletions docs/pages/component/props.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1000,3 +1000,68 @@ Adjust the volume.
- **1.0 (default)** - Play at full volume
- **0.0** - Mute the audio
- **Other values** - Reduce volume

### `cmcd`

<PlatformsList types={['Android']} />

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
<Video
source={{
uri: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8',
cmcd: {
mode: CmcdMode.MODE_QUERY_PARAMETER,
request: {
'com-custom-key': 'custom-value'
},
session: {
sid: 'session-id'
},
object: {
br: '3000',
d: '4000'
},
status: {
rtp: '1200'
}
}
}}
// or other video props
/>
```
32 changes: 30 additions & 2 deletions src/Video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import type {
ImageResizeMode,
} from 'react-native';

import NativeVideoComponent from './specs/VideoNativeComponent';
import NativeVideoComponent, {
NativeCmcdConfiguration,
} from './specs/VideoNativeComponent';
import type {
OnAudioFocusChangedData,
OnAudioTracksData,
Expand Down Expand Up @@ -44,12 +46,13 @@ import {
} from './utils';
import NativeVideoManager from './specs/NativeVideoManager';
import type {VideoSaveData} from './specs/NativeVideoManager';
import {ViewType} from './types';
import {CmcdMode, ViewType} from './types';
import type {
OnLoadData,
OnTextTracksData,
OnReceiveAdEventData,
ReactVideoProps,
CmcdData,
} from './types';

export interface VideoRef {
Expand Down Expand Up @@ -176,6 +179,30 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
multiDrm: selectedDrm.multiDrm,
};

let _cmcd: NativeCmcdConfiguration | undefined;
if (Platform.OS === 'android' && source?.cmcd) {
const cmcd = source.cmcd;

if (typeof cmcd === 'boolean') {
_cmcd = cmcd ? {mode: CmcdMode.MODE_QUERY_PARAMETER} : undefined;
} else if (typeof cmcd === 'object' && !Array.isArray(cmcd)) {
const createCmcdHeader = (property?: CmcdData) =>
property ? generateHeaderForNative(property) : undefined;

_cmcd = {
mode: cmcd.mode ?? CmcdMode.MODE_QUERY_PARAMETER,
request: createCmcdHeader(cmcd.request),
session: createCmcdHeader(cmcd.session),
object: createCmcdHeader(cmcd.object),
status: createCmcdHeader(cmcd.status),
};
} else {
throw new Error(
'Invalid CMCD configuration: Expected a boolean or an object.',
);
}
}

return {
uri,
isNetwork,
Expand All @@ -190,6 +217,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
cropEnd: resolvedSource.cropEnd,
metadata: resolvedSource.metadata,
drm: _drm,
cmcd: _cmcd,
textTracksAllowChunklessPreparation:
resolvedSource.textTracksAllowChunklessPreparation,
};
Expand Down
10 changes: 10 additions & 0 deletions src/specs/VideoNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type VideoSrc = Readonly<{
cropEnd?: Float;
metadata?: VideoMetadata;
drm?: Drm;
cmcd?: NativeCmcdConfiguration; // android
textTracksAllowChunklessPreparation?: boolean; // android
}>;

Expand All @@ -61,6 +62,15 @@ type Drm = Readonly<{
multiDrm?: WithDefault<boolean, false>; // android
}>;

type CmcdMode = WithDefault<Int32, 1>;
export type NativeCmcdConfiguration = Readonly<{
mode?: CmcdMode; // default: MODE_QUERY_PARAMETER
request?: Headers;
session?: Headers;
object?: Headers;
status?: Headers;
}>;

type TextTracks = ReadonlyArray<
Readonly<{
title: string;
Expand Down
22 changes: 22 additions & 0 deletions src/types/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type ReactVideoSourceProperties = {
cropEnd?: number;
metadata?: VideoMetadata;
drm?: Drm;
cmcd?: Cmcd; // android
textTracksAllowChunklessPreparation?: boolean;
};

Expand Down Expand Up @@ -87,6 +88,27 @@ export type Drm = Readonly<{
/* eslint-enable @typescript-eslint/no-unused-vars */
}>;

export enum CmcdMode {
MODE_REQUEST_HEADER = 0,
MODE_QUERY_PARAMETER = 1,
}
/**
* Custom key names MUST carry a hyphenated prefix to ensure that there will not be a
* namespace collision with future revisions to this specification. Clients SHOULD
* use a reverse-DNS syntax when defining their own prefix.
*
* @see https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf CTA-5004 Specification (Page 6, Section 3.1)
*/
export type CmcdData = Record<`${string}-${string}`, string | number>;
export type CmcdConfiguration = Readonly<{
mode?: CmcdMode; // default: MODE_QUERY_PARAMETER
request?: CmcdData;
session?: CmcdData;
object?: CmcdData;
status?: CmcdData;
}>;
export type Cmcd = boolean | CmcdConfiguration;

export enum BufferingStrategyType {
DEFAULT = 'Default',
DISABLE_BUFFERING = 'DisableBuffering',
Expand Down

0 comments on commit ca795f2

Please sign in to comment.