diff --git a/CHANGELOG.md b/CHANGELOG.md index 177de6dd9..dfa2501d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Changed + +- Android: Refactored AdaptationConfig to enable the `onVideoAdaptation` callback + +### Added + +- Android: Added support for the `onVideoAdaptation` callback + ## [0.36.0] - 2024-12-20 ### Changed diff --git a/android/src/main/java/com/bitmovin/player/reactnative/AdaptationModule.kt b/android/src/main/java/com/bitmovin/player/reactnative/AdaptationModule.kt new file mode 100644 index 000000000..3d087b4e7 --- /dev/null +++ b/android/src/main/java/com/bitmovin/player/reactnative/AdaptationModule.kt @@ -0,0 +1,99 @@ +package com.bitmovin.player.reactnative + +import android.util.Log +import androidx.concurrent.futures.CallbackToFutureAdapter +import androidx.concurrent.futures.CallbackToFutureAdapter.Completer +import com.bitmovin.player.api.media.AdaptationConfig +import com.bitmovin.player.api.media.video.quality.VideoAdaptation +import com.bitmovin.player.api.media.video.quality.VideoAdaptationData +import com.bitmovin.player.reactnative.converter.toAdaptationConfig +import com.bitmovin.player.reactnative.converter.toJson +import com.facebook.react.bridge.* +import com.facebook.react.module.annotations.ReactModule +import java.lang.Exception +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit + +private const val MODULE_NAME = "AdaptationModule" + +@ReactModule(name = MODULE_NAME) +class AdaptationModule(context: ReactApplicationContext) : BitmovinBaseModule(context) { + /** + * In-memory mapping from `nativeId`s to `AdaptationConfig` instances. + */ + private val adaptationConfigs: Registry = mutableMapOf() + private val onVideoAdaptationCompleters = ConcurrentHashMap>() + + override fun getName() = MODULE_NAME + + fun getConfig(nativeId: NativeId?): AdaptationConfig? = nativeId?.let { adaptationConfigs[it] } + + @ReactMethod + fun initWithConfig(nativeId: NativeId, config: ReadableMap, promise: Promise) { + promise.unit.resolveOnUiThread { + if (adaptationConfigs.containsKey(nativeId)) { + return@resolveOnUiThread + } + val adaptationConfig = config.toAdaptationConfig() + adaptationConfigs[nativeId] = adaptationConfig + initConfigBlocks(nativeId, config) + } + } + + @ReactMethod + fun destroy(nativeId: NativeId) { + adaptationConfigs.remove(nativeId) + onVideoAdaptationCompleters.keys.filter { it.startsWith(nativeId) }.forEach { + onVideoAdaptationCompleters.remove(it) + } + } + + private fun initConfigBlocks(nativeId: String, config: ReadableMap) { + initVideoAdaptationData(nativeId, adaptationConfigJson = config) + } + + private fun initVideoAdaptationData(nativeId: NativeId, adaptationConfigJson: ReadableMap) { + val adaptationConfig = getConfig(nativeId) ?: return + if (!adaptationConfigJson.hasKey("videoAdaptation")) return + + adaptationConfig.videoAdaptation = VideoAdaptation { data -> + val future = onVideoAdaptationFromJS(nativeId, data) + try { + val callbackValue = future.get(1, TimeUnit.SECONDS) // set timeout to mimize playback performance impact + callbackValue + } catch (e: Exception) { + Log.e(MODULE_NAME, "custom RN onVideoAdaptation exception $e. Using default of ${data.suggested}") + data.suggested + } + } + } + + private fun onVideoAdaptationFromJS( + nativeId: NativeId, + data: VideoAdaptationData, + ): Future { + val onVideoAdaptationId = "$nativeId@${System.identityHashCode(data)}" + val args = Arguments.createArray() + args.pushString(onVideoAdaptationId) + args.pushMap(data.toJson()) + + return CallbackToFutureAdapter.getFuture { completer -> + onVideoAdaptationCompleters[onVideoAdaptationId] = completer + context.catalystInstance.callFunction("Adaptation-$nativeId", "onVideoAdaptation", args as NativeArray) + } + } + + @ReactMethod + fun setOnVideoAdaptation(onVideoAdaptationId: String, data: String) { + val completer = onVideoAdaptationCompleters.remove(onVideoAdaptationId) + if (completer == null) { + Log.e( + MODULE_NAME, + "Completer is null for onVideoAdaptationId: $onVideoAdaptationId, this can cause adaptation errors", + ) + return + } + completer.set(data) + } +} diff --git a/android/src/main/java/com/bitmovin/player/reactnative/BitmovinBaseModule.kt b/android/src/main/java/com/bitmovin/player/reactnative/BitmovinBaseModule.kt index 145f455b3..d443340a7 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/BitmovinBaseModule.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/BitmovinBaseModule.kt @@ -3,6 +3,7 @@ package com.bitmovin.player.reactnative import android.util.Log import com.bitmovin.player.api.Player import com.bitmovin.player.api.source.Source +import com.bitmovin.player.reactnative.extensions.adaptationModule import com.bitmovin.player.reactnative.extensions.drmModule import com.bitmovin.player.reactnative.extensions.networkModule import com.bitmovin.player.reactnative.extensions.offlineModule @@ -58,6 +59,9 @@ abstract class BitmovinBaseModule( protected val RejectPromiseOnExceptionBlock.networkModule: NetworkModule get() = context.networkModule ?: throw IllegalStateException("NetworkModule not found") + protected val RejectPromiseOnExceptionBlock.adaptationModule: AdaptationModule get() = context.adaptationModule + ?: throw IllegalStateException("AdaptationModule not found") + fun RejectPromiseOnExceptionBlock.getPlayer( nativeId: NativeId, playerModule: PlayerModule = this.playerModule, diff --git a/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt b/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt index b4a45185d..13c5d0f7a 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt @@ -43,8 +43,21 @@ class PlayerModule(context: ReactApplicationContext) : BitmovinBaseModule(contex * @param config `PlayerConfig` object received from JS. */ @ReactMethod - fun initWithConfig(nativeId: NativeId, config: ReadableMap?, networkNativeId: NativeId?, promise: Promise) { - init(nativeId, config, networkNativeId = networkNativeId, analyticsConfigJson = null, promise) + fun initWithConfig( + nativeId: NativeId, + config: ReadableMap?, + adaptationNativeId: NativeId?, + networkNativeId: NativeId?, + promise: Promise, + ) { + init( + nativeId, + config, + adaptationNativeId = adaptationNativeId, + networkNativeId = networkNativeId, + analyticsConfigJson = null, + promise, + ) } /** @@ -56,14 +69,16 @@ class PlayerModule(context: ReactApplicationContext) : BitmovinBaseModule(contex fun initWithAnalyticsConfig( nativeId: NativeId, playerConfigJson: ReadableMap?, + adaptationNativeId: NativeId?, networkNativeId: NativeId?, analyticsConfigJson: ReadableMap, promise: Promise, - ) = init(nativeId, playerConfigJson, networkNativeId, analyticsConfigJson, promise) + ) = init(nativeId, playerConfigJson, adaptationNativeId, networkNativeId, analyticsConfigJson, promise) private fun init( nativeId: NativeId, playerConfigJson: ReadableMap?, + adaptationNativeId: NativeId?, networkNativeId: NativeId?, analyticsConfigJson: ReadableMap?, promise: Promise, @@ -85,6 +100,11 @@ class PlayerModule(context: ReactApplicationContext) : BitmovinBaseModule(contex playerConfig.networkConfig = networkConfig } + val adaptationConfig = adaptationNativeId?.let { adaptationModule.getConfig(it) } + if (adaptationConfig != null) { + playerConfig.adaptationConfig = adaptationConfig + } + players[nativeId] = if (analyticsConfig == null) { Player.create(context, playerConfig) } else { diff --git a/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerViewPackage.kt b/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerViewPackage.kt index 7d3ab01f6..09c0c6e56 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerViewPackage.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerViewPackage.kt @@ -29,6 +29,7 @@ class RNPlayerViewPackage : ReactPackage { CustomMessageHandlerModule(reactContext), BitmovinCastManagerModule(reactContext), BufferModule(reactContext), + AdaptationModule(reactContext), NetworkModule(reactContext), DebugModule(reactContext), ) diff --git a/android/src/main/java/com/bitmovin/player/reactnative/converter/JsonConverter.kt b/android/src/main/java/com/bitmovin/player/reactnative/converter/JsonConverter.kt index 15d5c53db..dafe1fdd8 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/converter/JsonConverter.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/converter/JsonConverter.kt @@ -36,6 +36,7 @@ import com.bitmovin.player.api.media.audio.AudioTrack import com.bitmovin.player.api.media.subtitle.SubtitleTrack import com.bitmovin.player.api.media.thumbnail.Thumbnail import com.bitmovin.player.api.media.thumbnail.ThumbnailTrack +import com.bitmovin.player.api.media.video.quality.VideoAdaptationData import com.bitmovin.player.api.media.video.quality.VideoQuality import com.bitmovin.player.api.network.HttpRequest import com.bitmovin.player.api.network.HttpRequestType @@ -144,11 +145,17 @@ private fun String.toTimelineReferencePoint(): TimelineReferencePoint? = when (t /** * Converts an arbitrary `json` to `AdaptationConfig`. */ -private fun ReadableMap.toAdaptationConfig(): AdaptationConfig = AdaptationConfig().apply { +fun ReadableMap.toAdaptationConfig(): AdaptationConfig = AdaptationConfig().apply { withInt("maxSelectableBitrate") { maxSelectableVideoBitrate = it } withInt("initialBandwidthEstimateOverride") { initialBandwidthEstimateOverride = it.toLong(); } } +fun ReadableMap.toVideoAdaptationData(): VideoAdaptationData? { + return VideoAdaptationData( + getString("suggested") ?: return null, + ) +} + /** * Converts any JS object into a `PlaybackConfig` object. */ @@ -905,6 +912,10 @@ fun RNBufferLevels.toJson(): WritableMap = Arguments.createMap().apply { putMap("video", video.toJson()) } +fun VideoAdaptationData.toJson(): WritableMap = Arguments.createMap().apply { + putString("suggested", suggested) +} + /** * Maps a JS string into the corresponding [BufferType] value. */ diff --git a/android/src/main/java/com/bitmovin/player/reactnative/extensions/ReactContextExtension.kt b/android/src/main/java/com/bitmovin/player/reactnative/extensions/ReactContextExtension.kt index 64090363c..9846ec83b 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/extensions/ReactContextExtension.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/extensions/ReactContextExtension.kt @@ -1,5 +1,6 @@ package com.bitmovin.player.reactnative.extensions +import com.bitmovin.player.reactnative.AdaptationModule import com.bitmovin.player.reactnative.DrmModule import com.bitmovin.player.reactnative.NetworkModule import com.bitmovin.player.reactnative.OfflineModule @@ -20,3 +21,4 @@ val ReactApplicationContext.uiManagerModule get() = getModule() val ReactApplicationContext.drmModule get() = getModule() val ReactApplicationContext.customMessageHandlerModule get() = getModule() val ReactApplicationContext.networkModule get() = getModule() +val ReactApplicationContext.adaptationModule get() = getModule() diff --git a/src/adaptation/adaptationConfig.ts b/src/adaptation/adaptationConfig.ts new file mode 100644 index 000000000..defbaa732 --- /dev/null +++ b/src/adaptation/adaptationConfig.ts @@ -0,0 +1,52 @@ +import { NativeInstanceConfig } from '../nativeInstance'; + +/** + * Can be implemented and added to the AdaptationConfig to customize the video adaptation logic. + * + * @platform Android + */ +export interface VideoAdaptation { + /** + * Is called before the next video segment is downloaded. The quality according to VideoQuality.id that is returned will be downloaded next. Invalid IDs or null will result in a fallback to the ID provided in data. + * + * @platform Android + * @see https://cdn.bitmovin.com/player/android/3/docs/player-core/com.bitmovin.player.api.media.video.quality/-video-adaptation/on-video-adaptation.html + */ + onVideoAdaptation?: (data: VideoAdaptationData) => Promise; +} + +/** + * Holds information about the current video adaptation. + * + * @platform Android + */ +export interface VideoAdaptationData { + suggested?: string; +} + +/** + * Configures the adaptation logic. + */ +export interface AdaptationConfig extends NativeInstanceConfig { + /** + * The upper bitrate boundary in bits per second for approximate network bandwidth consumption of the played source. + * Can be set to `undefined` for no limitation. + */ + maxSelectableBitrate?: number; + + /** + * The initial bandwidth estimate in bits per second the player uses to select the optimal media tracks before actual bandwidth data is available. Overriding this value should only be done in specific cases and will most of the time not result in better selection logic. + * + * @platform Android + * @see https://cdn.bitmovin.com/player/android/3/docs/player-core/com.bitmovin.player.api.media/-adaptation-config/initial-bandwidth-estimate-override.html + */ + initialBandwidthEstimateOverride?: number; + + /** + * A callback to customize the player's adaptation logic. VideoAdaptation.onVideoAdaptation is called before the player tries to download a new video segment. + * + * @platform Android + * @see https://cdn.bitmovin.com/player/android/3/docs/player-core/com.bitmovin.player.api.media/-adaptation-config/video-adaptation.html + */ + videoAdaptation?: VideoAdaptation; +} diff --git a/src/adaptation/index.ts b/src/adaptation/index.ts new file mode 100644 index 000000000..57e42bbde --- /dev/null +++ b/src/adaptation/index.ts @@ -0,0 +1,69 @@ +import { NativeModules } from 'react-native'; +import BatchedBridge from 'react-native/Libraries/BatchedBridge/BatchedBridge'; +import NativeInstance from '../nativeInstance'; +import { VideoAdaptationData, AdaptationConfig } from './adaptationConfig'; + +// Export config types from Adaptation module. +export { VideoAdaptationData, AdaptationConfig }; + +const AdaptationModule = NativeModules.AdaptationModule; + +/** + * Represents a native Adaptation configuration object. + * @internal + */ +export class Adaptation extends NativeInstance { + /** + * Whether this object's native instance has been created. + */ + isInitialized = false; + /** + * Whether this object's native instance has been disposed. + */ + isDestroyed = false; + + /** + * Allocates the Adaptation config instance and its resources natively. + */ + initialize = () => { + if (!this.isInitialized) { + // Register this object as a callable module so it's possible to + // call functions on it from native code, e.g `onVideoAdaptation`. + BatchedBridge.registerCallableModule(`Adaptation-${this.nativeId}`, this); + // Create native configuration object. + AdaptationModule.initWithConfig(this.nativeId, this.config); + this.isInitialized = true; + } + }; + + /** + * Destroys the native Adaptation config and releases all of its allocated resources. + */ + destroy = () => { + if (!this.isDestroyed) { + AdaptationModule.destroy(this.nativeId); + this.isDestroyed = true; + } + }; + + /** + * Applies the user-defined `onVideoAdaptation` function to native's `data` and store + * the result back in `AdaptationModule`. + * + * Called from native code when `AdaptationConfig.videoAdaptation` is dispatched. + * + * @param requestId Passed through to identify the completion handler of the request on native. + * @param data The quality according to VideoQuality.id that is returned will be downloaded next. + */ + + onVideoAdaptation = (requestId: string, data: VideoAdaptationData) => { + this.config?.videoAdaptation + ?.onVideoAdaptation?.(data) + .then((resultData: string) => { + AdaptationModule.setOnVideoAdaptation(requestId, resultData); + }) + .catch(() => { + AdaptationModule.setOnVideoAdaptation(requestId, data); + }); + }; +} diff --git a/src/adaptationConfig.ts b/src/adaptationConfig.ts deleted file mode 100644 index 16b6baa41..000000000 --- a/src/adaptationConfig.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Configures the adaptation logic. - */ -export interface AdaptationConfig { - /** - * The upper bitrate boundary in bits per second for approximate network bandwidth consumption of the played source. - * Can be set to `undefined` for no limitation. - */ - maxSelectableBitrate?: number; - - /** - * The initial bandwidth estimate in bits per second the player uses to select the optimal media tracks before actual bandwidth data is available. Overriding this value should only be done in specific cases and will most of the time not result in better selection logic. - * - * @platform Android - * @see https://cdn.bitmovin.com/player/android/3/docs/player-core/com.bitmovin.player.api.media/-adaptation-config/initial-bandwidth-estimate-override.html - */ - initialBandwidthEstimateOverride?: number; -} diff --git a/src/index.ts b/src/index.ts index 4ec7abdd1..e0ba69933 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -export * from './adaptationConfig'; export * from './advertising'; export * from './analytics'; export * from './audioSession'; @@ -24,5 +23,6 @@ export * from './playerConfig'; export * from './liveConfig'; export * from './bufferApi'; export * from './network'; +export * from './adaptation'; export * from './mediaControlConfig'; export * from './debug'; diff --git a/src/player.ts b/src/player.ts index e936a87e8..0051e52eb 100644 --- a/src/player.ts +++ b/src/player.ts @@ -11,6 +11,7 @@ import { AdItem } from './advertising'; import { BufferApi } from './bufferApi'; import { VideoQuality } from './media'; import { Network } from './network'; +import { Adaptation } from './adaptation'; const PlayerModule = NativeModules.PlayerModule; @@ -24,6 +25,7 @@ const PlayerModule = NativeModules.PlayerModule; * @see PlayerView */ export class Player extends NativeInstance { + private adaptation?: Adaptation; private network?: Network; /** * Currently active source, or `null` if none is active. @@ -57,11 +59,16 @@ export class Player extends NativeInstance { this.network = new Network(this.config.networkConfig); this.network.initialize(); } + if (this.config?.adaptationConfig) { + this.adaptation = new Adaptation(this.config.adaptationConfig); + this.adaptation.initialize(); + } const analyticsConfig = this.config?.analyticsConfig; if (analyticsConfig) { PlayerModule.initWithAnalyticsConfig( this.nativeId, this.config, + this.adaptation?.nativeId, this.network?.nativeId, analyticsConfig ); @@ -70,6 +77,7 @@ export class Player extends NativeInstance { PlayerModule.initWithConfig( this.nativeId, this.config, + this.adaptation?.nativeId, this.network?.nativeId ); } @@ -84,6 +92,7 @@ export class Player extends NativeInstance { if (!this.isDestroyed) { PlayerModule.destroy(this.nativeId); this.source?.destroy(); + this.adaptation?.destroy(); this.network?.destroy(); this.isDestroyed = true; } diff --git a/src/playerConfig.ts b/src/playerConfig.ts index e6a686900..40d07b069 100644 --- a/src/playerConfig.ts +++ b/src/playerConfig.ts @@ -2,7 +2,7 @@ import { AdvertisingConfig } from './advertising'; import { AnalyticsConfig } from './analytics'; import { StyleConfig } from './styleConfig'; import { TweaksConfig } from './tweaksConfig'; -import { AdaptationConfig } from './adaptationConfig'; +import { AdaptationConfig } from './adaptation/adaptationConfig'; import { RemoteControlConfig } from './remoteControlConfig'; import { BufferConfig } from './bufferConfig'; import { NativeInstanceConfig } from './nativeInstance';