diff --git a/CHANGELOG.md b/CHANGELOG.md index c000e116..7565d3f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - `DefaultMetadata` for configuration of the bundled analytics collector - `Player.analytics` to access the `AnalyticsApi` and interact with the bundled analytics collector - `SourceConfig.analyticsSourceMetadata` for extended configuration of the bundled analytics collector +- Google Cast SDK support for Android and iOS ### Changed diff --git a/android/src/main/java/com/bitmovin/player/reactnative/BitmovinCastManagerModule.kt b/android/src/main/java/com/bitmovin/player/reactnative/BitmovinCastManagerModule.kt new file mode 100644 index 00000000..a70f5ba3 --- /dev/null +++ b/android/src/main/java/com/bitmovin/player/reactnative/BitmovinCastManagerModule.kt @@ -0,0 +1,76 @@ +package com.bitmovin.player.reactnative + +import com.bitmovin.player.casting.BitmovinCastManager +import com.bitmovin.player.reactnative.converter.JsonConverter +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.UIManagerModule + +private const val MODULE_NAME = "BitmovinCastManagerModule" + +@ReactModule(name = MODULE_NAME) +class BitmovinCastManagerModule( + private val context: ReactApplicationContext, +) : ReactContextBaseJavaModule(context) { + override fun getName() = MODULE_NAME + + /** + * Returns whether the [BitmovinCastManager] is initialized. + */ + @ReactMethod + fun isInitialized(promise: Promise) = uiManager?.addUIBlock { + promise.resolve(BitmovinCastManager.isInitialized()) + } + + /** + * Initializes the [BitmovinCastManager] with the given options. + */ + @ReactMethod + fun initializeCastManager(options: ReadableMap?, promise: Promise) { + val castOptions = JsonConverter.toCastOptions(options) + uiManager?.addUIBlock { + BitmovinCastManager.initialize( + castOptions?.applicationId, + castOptions?.messageNamespace + ) + promise.resolve(null) + } + } + + /** + * Sends a message to the receiver. + */ + @ReactMethod + fun sendMessage(message: String, messageNamespace: String?, promise: Promise) { + uiManager?.addUIBlock { + BitmovinCastManager.getInstance().sendMessage(message, messageNamespace) + promise.resolve(null) + } + } + + /** + * Updates the context of the [BitmovinCastManager] to the current activity. + */ + @ReactMethod + fun updateContext(promise: Promise) { + uiManager?.addUIBlock { + BitmovinCastManager.getInstance().updateContext(currentActivity) + promise.resolve(null) + } + } + + private val uiManager: UIManagerModule? + get() = context.getNativeModule(UIManagerModule::class.java) +} + +/** + * Represents configuration options for the [BitmovinCastManager]. + */ +data class BitmovinCastManagerOptions( + val applicationId: String? = null, + val messageNamespace: String? = null, +) 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 c403745b..b144b34b 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt @@ -4,6 +4,7 @@ import android.util.Log import com.bitmovin.analytics.api.DefaultMetadata import com.bitmovin.player.api.Player import com.bitmovin.player.api.analytics.create +import com.bitmovin.player.api.event.PlayerEvent import com.bitmovin.player.reactnative.converter.JsonConverter import com.facebook.react.bridge.* import com.facebook.react.module.annotations.ReactModule @@ -496,6 +497,48 @@ class PlayerModule(private val context: ReactApplicationContext) : ReactContextB } } + /** + * Initiates casting the current video to a cast-compatible remote device. The user has to choose to which device it + * should be sent. + */ + @ReactMethod + fun castVideo(nativeId: NativeId) { + uiManager()?.addUIBlock { + players[nativeId]?.castVideo() + } + } + + /** + * Stops casting the current video. Has no effect if [isCasting] is false. + */ + @ReactMethod + fun castStop(nativeId: NativeId) { + uiManager()?.addUIBlock { + players[nativeId]?.castStop() + } + } + + /** + * Whether casting to a cast-compatible remote device is available. [PlayerEvent.CastAvailable] signals when + * casting becomes available. + */ + @ReactMethod + fun isCastAvailable(nativeId: NativeId, promise: Promise) { + uiManager()?.addUIBlock { + promise.resolve(players[nativeId]?.isCastAvailable) + } + } + + /** + * Whether video is currently being casted to a remote device and not played locally. + */ + @ReactMethod + fun isCasting(nativeId: NativeId, promise: Promise) { + uiManager()?.addUIBlock { + promise.resolve(players[nativeId]?.isCasting) + } + } + /** * Helper function that returns the initialized `UIManager` instance. */ diff --git a/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerView.kt b/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerView.kt index 4ca7beb5..02823aa5 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerView.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerView.kt @@ -62,6 +62,18 @@ private val EVENT_CLASS_TO_REACT_NATIVE_NAME_MAPPING = mapOf( PlayerEvent.AdSkipped::class to "adSkipped", PlayerEvent.AdStarted::class to "adStarted", PlayerEvent.VideoPlaybackQualityChanged::class to "videoPlaybackQualityChanged", + PlayerEvent.CastStart::class to "castStart", + @Suppress("DEPRECATION") + PlayerEvent.CastPlaybackFinished::class to "castPlaybackFinished", + @Suppress("DEPRECATION") + PlayerEvent.CastPaused::class to "castPaused", + @Suppress("DEPRECATION") + PlayerEvent.CastPlaying::class to "castPlaying", + PlayerEvent.CastStarted::class to "castStarted", + PlayerEvent.CastAvailable::class to "castAvailable", + PlayerEvent.CastStopped::class to "castStopped", + PlayerEvent.CastWaitingForDevice::class to "castWaitingForDevice", + PlayerEvent.CastTimeUpdated::class to "castTimeUpdated", ) private val EVENT_CLASS_TO_REACT_NATIVE_NAME_MAPPING_UI = mapOf, String>( diff --git a/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerViewManager.kt b/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerViewManager.kt index a3597eba..6df0d82b 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerViewManager.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerViewManager.kt @@ -113,6 +113,15 @@ class RNPlayerViewManager(private val context: ReactApplicationContext) : Simple "fullscreenDisabled" to "onFullscreenDisabled", "fullscreenEnter" to "onFullscreenEnter", "fullscreenExit" to "onFullscreenExit", + "castStart" to "onCastStart", + "castPlaybackFinished" to "onCastPlaybackFinished", + "castPaused" to "onCastPaused", + "castPlaying" to "onCastPlaying", + "castStarted" to "onCastStarted", + "castAvailable" to "onCastAvailable", + "castStopped" to "onCastStopped", + "castWaitingForDevice" to "onCastWaitingForDevice", + "castTimeUpdated" to "onCastTimeUpdated", ) /** 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 fad7dec4..f5327e13 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerViewPackage.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerViewPackage.kt @@ -27,7 +27,8 @@ class RNPlayerViewPackage : ReactPackage { PlayerAnalyticsModule(reactContext), RNPlayerViewManager(reactContext), FullscreenHandlerModule(reactContext), - CustomMessageHandlerModule(reactContext) + CustomMessageHandlerModule(reactContext), + BitmovinCastManagerModule(reactContext), ) } diff --git a/android/src/main/java/com/bitmovin/player/reactnative/SourceModule.kt b/android/src/main/java/com/bitmovin/player/reactnative/SourceModule.kt index 6079091f..d1d2d3e2 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/SourceModule.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/SourceModule.kt @@ -46,6 +46,7 @@ class SourceModule(private val context: ReactApplicationContext) : ReactContextB * @param nativeId ID to be associated with the `Source` instance. * @param drmNativeId ID of the DRM config to use. * @param config `SourceConfig` object received from JS. + * @param sourceRemoteControlConfig `SourceRemoteControlConfig` object received from JS. Not supported on Android. * @param analyticsSourceMetadata `SourceMetadata` object received from JS. */ @ReactMethod @@ -53,6 +54,7 @@ class SourceModule(private val context: ReactApplicationContext) : ReactContextB nativeId: NativeId, drmNativeId: NativeId?, config: ReadableMap?, + sourceRemoteControlConfig: ReadableMap?, analyticsSourceMetadata: ReadableMap? ) { uiManager()?.addUIBlock { @@ -69,12 +71,14 @@ class SourceModule(private val context: ReactApplicationContext) : ReactContextB * @param nativeId ID to be associated with the `Source` instance. * @param drmNativeId ID of the DRM config to use. * @param config `SourceConfig` object received from JS. + * @param sourceRemoteControlConfig `SourceRemoteControlConfig` object received from JS. Not supported on Android. */ @ReactMethod fun initWithConfig( nativeId: NativeId, drmNativeId: NativeId?, config: ReadableMap?, + sourceRemoteControlConfig: ReadableMap? ) { uiManager()?.addUIBlock { initializeSource(nativeId, drmNativeId, config) { sourceConfig -> 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 e6147c43..ba00af60 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 @@ -18,9 +18,11 @@ import com.bitmovin.player.api.advertising.AdQuartile import com.bitmovin.player.api.advertising.AdSource import com.bitmovin.player.api.advertising.AdSourceType import com.bitmovin.player.api.advertising.AdvertisingConfig +import com.bitmovin.player.api.casting.RemoteControlConfig import com.bitmovin.player.api.drm.WidevineConfig import com.bitmovin.player.api.event.PlayerEvent import com.bitmovin.player.api.event.SourceEvent +import com.bitmovin.player.api.event.data.CastPayload import com.bitmovin.player.api.event.data.SeekPosition import com.bitmovin.player.api.media.AdaptationConfig import com.bitmovin.player.api.media.audio.AudioTrack @@ -37,8 +39,10 @@ import com.bitmovin.player.api.source.SourceType import com.bitmovin.player.api.source.TimelineReferencePoint import com.bitmovin.player.api.ui.ScalingMode import com.bitmovin.player.api.ui.StyleConfig +import com.bitmovin.player.reactnative.BitmovinCastManagerOptions import com.bitmovin.player.reactnative.extensions.getBooleanOrNull import com.bitmovin.player.reactnative.extensions.getName +import com.bitmovin.player.reactnative.extensions.getOrDefault import com.bitmovin.player.reactnative.extensions.getProperty import com.bitmovin.player.reactnative.extensions.putBoolean import com.bitmovin.player.reactnative.extensions.putDouble @@ -96,9 +100,67 @@ class JsonConverter { playerConfig.adaptationConfig = it } } + if (json.hasKey("remoteControlConfig")) { + toRemoteControlConfig(json.getMap("remoteControlConfig"))?.let { + playerConfig.remoteControlConfig = it + } + } return playerConfig } + /** + * Converts an arbitrary [ReadableMap] to a [RemoteControlConfig]. + * + * @param json JS object representing the [RemoteControlConfig]. + * @return The generated [RemoteControlConfig]. + */ + private fun toRemoteControlConfig(json: ReadableMap?): RemoteControlConfig? { + if (json == null) return null + val defaultRemoteControlConfig = RemoteControlConfig() + + val receiverStylesheetUrl = json.getOrDefault( + "receiverStylesheetUrl", + defaultRemoteControlConfig.receiverStylesheetUrl + ) + + var customReceiverConfig = defaultRemoteControlConfig.customReceiverConfig + if (json.hasKey("customReceiverConfig")) { + customReceiverConfig = json.getMap("customReceiverConfig") + ?.toHashMap() + ?.mapValues { entry -> entry.value as String } ?: emptyMap() + } + + val isCastEnabled = json.getOrDefault( + "isCastEnabled", + defaultRemoteControlConfig.isCastEnabled + ) + + val sendManifestRequestsWithCredentials = json.getOrDefault( + "sendManifestRequestsWithCredentials", + defaultRemoteControlConfig.sendManifestRequestsWithCredentials + ) + + val sendSegmentRequestsWithCredentials = json.getOrDefault( + "sendSegmentRequestsWithCredentials", + defaultRemoteControlConfig.sendSegmentRequestsWithCredentials + ) + + val sendDrmLicenseRequestsWithCredentials = json.getOrDefault( + "sendDrmLicenseRequestsWithCredentials", + defaultRemoteControlConfig.sendDrmLicenseRequestsWithCredentials + ) + + + return RemoteControlConfig( + receiverStylesheetUrl = receiverStylesheetUrl, + customReceiverConfig = customReceiverConfig, + isCastEnabled = isCastEnabled, + sendManifestRequestsWithCredentials = sendManifestRequestsWithCredentials, + sendSegmentRequestsWithCredentials = sendSegmentRequestsWithCredentials, + sendDrmLicenseRequestsWithCredentials = sendDrmLicenseRequestsWithCredentials, + ) + } + /** * Converts an arbitrary `json` to `SourceOptions`. * @param json JS object representing the `SourceOptions`. @@ -123,7 +185,7 @@ class JsonConverter { "end" -> TimelineReferencePoint.End else -> null } - + /** * Converts an arbitrary `json` to `AdaptationConfig`. * @param json JS object representing the `AdaptationConfig`. @@ -564,6 +626,14 @@ class JsonConverter { json.putMap("oldVideoQuality", fromVideoQuality(event.oldVideoQuality)) } + is PlayerEvent.CastWaitingForDevice -> { + json.putMap("castPayload", fromCastPayload(event.castPayload)) + } + + is PlayerEvent.CastStarted -> { + json.putString("deviceName", event.deviceName) + } + else -> { // Event is not supported yet or does not have any additional data } @@ -571,6 +641,19 @@ class JsonConverter { return json } + /** + * Converts an arbitrary `json` into [BitmovinCastManagerOptions]. + * @param json JS object representing the [BitmovinCastManagerOptions]. + * @return The generated [BitmovinCastManagerOptions] if successful, `null` otherwise. + */ + fun toCastOptions(json: ReadableMap?): BitmovinCastManagerOptions? { + if (json == null) return null + return BitmovinCastManagerOptions( + json.getOrDefault("applicationId", null), + json.getOrDefault("messageNamespace", null) + ) + } + /** * Converts an arbitrary `json` to `WidevineConfig`. * @param json JS object representing the `WidevineConfig`. @@ -1008,3 +1091,12 @@ class JsonConverter { } } } + +/** + * Converts a [CastPayload] object into its JS representation. + */ +private fun fromCastPayload(castPayload: CastPayload) = Arguments.createMap().apply { + putDouble("currentTime", castPayload.currentTime) + putString("deviceName", castPayload.deviceName) + putString("type", castPayload.type) +} diff --git a/android/src/main/java/com/bitmovin/player/reactnative/extensions/ReadableMapExtension.kt b/android/src/main/java/com/bitmovin/player/reactnative/extensions/ReadableMapExtension.kt index 7c482229..8560eb3b 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/extensions/ReadableMapExtension.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/extensions/ReadableMapExtension.kt @@ -5,3 +5,21 @@ import com.facebook.react.bridge.ReadableMap fun ReadableMap.getBooleanOrNull( key: String ): Boolean? = takeIf { hasKey(key) }?.getBoolean(key) + +/** + * Reads the [Boolean] value from the given [ReadableMap] if the [key] is present. + * Returns the [default] value otherwise. + */ +fun ReadableMap.getOrDefault( + key: String, + default: Boolean, +) = if (hasKey(key)) getBoolean(key) else default + +/** + * Reads the [String] value from the given [ReadableMap] if the [key] is present. + * Returns the [default] value otherwise. + */ +fun ReadableMap.getOrDefault( + key: String, + default: String?, +) = if (hasKey(key)) getString(key) else default diff --git a/example/README.md b/example/README.md index df7c8266..221b6a65 100644 --- a/example/README.md +++ b/example/README.md @@ -14,6 +14,7 @@ inside the [`src/screens/`](https://github.com/bitmovin/bitmovin-player-react-na - [Basic Analytics](https://github.com/bitmovin/bitmovin-player-react-native/blob/development/example/src/screens/BasicAnalytics.tsx) - [Basic Offline Playback](https://github.com/bitmovin/bitmovin-player-react-native/blob/development/example/src/screens/OfflinePlayback.tsx) (iOS and Android only) - [System UI](https://github.com/bitmovin/bitmovin-player-react-native/blob/development/example/src/screens/SystemUi.tsx) (iOS and tvOS only) +- [Casting](https://github.com/bitmovin/bitmovin-player-react-native/blob/development/example/src/screens/Casting.tsx) (iOS and Android only) ### Custom asset playback diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index eee992a7..4183f6b5 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -255,6 +255,10 @@ dependencies { // Only needed if the offline feature is used implementation "androidx.localbroadcastmanager:localbroadcastmanager:1.1.0" + // only needed if the casting feature is used + implementation("com.google.android.gms:play-services-cast-framework:21.3.0") + implementation("androidx.mediarouter:mediarouter:1.3.1") + implementation "com.facebook.react:react-native:+" // From node_modules implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" debugImplementation("com.facebook.flipper:flipper:$FLIPPER_VERSION") { diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 9788447d..da2c0e41 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -29,5 +29,25 @@ + + + + + + + + + + + diff --git a/example/android/app/src/main/java/com/bitmovin/player/reactnative/example/MainActivity.java b/example/android/app/src/main/java/com/bitmovin/player/reactnative/example/MainActivity.java index b9c1b4ae..7bdb45b9 100644 --- a/example/android/app/src/main/java/com/bitmovin/player/reactnative/example/MainActivity.java +++ b/example/android/app/src/main/java/com/bitmovin/player/reactnative/example/MainActivity.java @@ -5,11 +5,19 @@ import com.facebook.react.ReactActivity; import com.facebook.react.ReactActivityDelegate; import com.facebook.react.ReactRootView; +import com.google.android.gms.cast.framework.CastContext; public class MainActivity extends ReactActivity { @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(null); + try { + // Load Google Cast context eagerly in order to ensure that + // the cast state is updated correctly. + CastContext.getSharedInstance(this, Runnable::run); + } catch (Exception e) { + // cast framework not supported + } } /** diff --git a/example/ios/BitmovinPlayerReactNativeExample.xcodeproj/project.pbxproj b/example/ios/BitmovinPlayerReactNativeExample.xcodeproj/project.pbxproj index 4e8de29c..33f94a2d 100644 --- a/example/ios/BitmovinPlayerReactNativeExample.xcodeproj/project.pbxproj +++ b/example/ios/BitmovinPlayerReactNativeExample.xcodeproj/project.pbxproj @@ -423,10 +423,16 @@ inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-BitmovinPlayerReactNativeExample/Pods-BitmovinPlayerReactNativeExample-resources.sh", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core-iOS/AccessibilityResources.bundle", + "${PODS_ROOT}/google-cast-sdk/GoogleCastSDK-ios-4.8.0_static/GoogleCast.framework/GoogleCastCoreResources.bundle", + "${PODS_ROOT}/google-cast-sdk/GoogleCastSDK-ios-4.8.0_static/GoogleCast.framework/GoogleCastUIResources.bundle", + "${PODS_ROOT}/google-cast-sdk/GoogleCastSDK-ios-4.8.0_static/GoogleCast.framework/MaterialDialogs.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleCastCoreResources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleCastUIResources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialDialogs.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/example/ios/Podfile b/example/ios/Podfile index 9115ae26..ded5ffc8 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -38,6 +38,7 @@ end target 'BitmovinPlayerReactNativeExample' do setup :ios + pod 'google-cast-sdk', '4.8.0' end target 'BitmovinPlayerReactNativeExample-tvOS' do diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 3b6de35b..78d7f03e 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -80,10 +80,16 @@ PODS: - FlipperKit/FlipperKitNetworkPlugin - fmt (7.1.3) - glog (0.3.5) + - google-cast-sdk (4.8.0): + - google-cast-sdk/Core (= 4.8.0) + - Protobuf (~> 3.13) + - google-cast-sdk/Core (4.8.0): + - Protobuf (~> 3.13) - GoogleAds-IMA-iOS-SDK (3.18.4) - GoogleAds-IMA-tvOS-SDK (4.8.2) - libevent (2.1.12.1) - OpenSSL-Universal (1.1.1100) + - Protobuf (3.24.3) - RCT-Folly (2021.06.28.00-v2): - boost - DoubleConversion @@ -382,6 +388,7 @@ DEPENDENCIES: - FlipperKit/SKIOSNetworkPlugin (= 0.151.0) - fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`) - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) + - google-cast-sdk (= 4.8.0) - OpenSSL-Universal (= 1.1.1100) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`) @@ -433,9 +440,11 @@ SPEC REPOS: - Flipper-Glog - Flipper-RSocket - FlipperKit + - google-cast-sdk - GoogleAds-IMA-iOS-SDK - GoogleAds-IMA-tvOS-SDK - OpenSSL-Universal + - Protobuf - SocketRocket - YogaKit @@ -533,10 +542,12 @@ SPEC CHECKSUMS: FlipperKit: feec58b1283d3560c703adc670c45ecf400c48d9 fmt: 135c0c55547979f715b56dfa54037ececa96d07a glog: bac6d5aa2990176cc22d0432fb3e28805d580aeb + google-cast-sdk: afeb1aac0744b1bc4f70bc3db8468e33fabbff38 GoogleAds-IMA-iOS-SDK: b01284e3bf3d64ba948de6692ffda531452c3713 GoogleAds-IMA-tvOS-SDK: 2dda9d3b34c43003222d3417315fecec22b698a1 libevent: a6d75fcd7be07cbc5070300ea8dbc8d55dfab88e OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c + Protobuf: 970f7ee93a3a08e3cf64859b8efd95ee32b4f87f RCT-Folly: 6955e7728b76277c5df5a5aba37ee1ff1dd99976 RCTRequired: 4a18f06da2ea5eb7e1b7334643f988ef76159c9e RCTTypeSafety: 3006f0658ff73d9e81a5fde4dbf9e9260a679ab0 @@ -570,6 +581,6 @@ SPEC CHECKSUMS: Yoga: be4fede0339d862989f5b064f8939c7429ad92c9 YogaKit: f782866e155069a2cca2517aafea43200b01fd5a -PODFILE CHECKSUM: ae31af0f37bdf647197475f3f0d4a5a3e93df60c +PODFILE CHECKSUM: c212c97d06bc742b7a8cc429dd03ec4c059fe021 COCOAPODS: 1.12.1 diff --git a/example/src/App.tsx b/example/src/App.tsx index 3e0d79f5..97e2bda2 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -20,6 +20,7 @@ import BasicFullscreenHandling from './screens/BasicFullscreenHandling'; import LandscapeFullscreenHandling from './screens/LandscapeFullscreenHandling'; import SystemUI from './screens/SystemUi'; import OfflinePlayback from './screens/OfflinePlayback'; +import Casting from './screens/Casting'; export type RootStackParamsList = { ExamplesList: { @@ -53,6 +54,7 @@ export type RootStackParamsList = { CustomHtmlUi: { navigation: NativeStackNavigationProp; }; + Casting: undefined; }; const RootStack = createNativeStackNavigator(); @@ -114,6 +116,10 @@ export default function App() { title: 'Landscape Fullscreen handling', routeName: 'LandscapeFullscreenHandling', }); + stackParams.data.push({ + title: 'Casting', + routeName: 'Casting', + }); } if (Platform.OS === 'ios' && !Platform.isTV) { @@ -228,6 +234,13 @@ export default function App() { options={{ title: 'System UI' }} /> )} + {!Platform.isTV && ( + + )} ); diff --git a/example/src/screens/BasicAds.tsx b/example/src/screens/BasicAds.tsx index 8d7086f1..a2d1dfb0 100644 --- a/example/src/screens/BasicAds.tsx +++ b/example/src/screens/BasicAds.tsx @@ -54,10 +54,14 @@ const advertisingConfig = { ], }; +const remoteControlConfig = { + isCastEnabled: false, +}; + export default function BasicAds() { useTVGestures(); - const player = usePlayer({ advertisingConfig }); + const player = usePlayer({ advertisingConfig, remoteControlConfig }); useFocusEffect( useCallback(() => { diff --git a/example/src/screens/BasicAnalytics.tsx b/example/src/screens/BasicAnalytics.tsx index 0f0ffe7c..fb6bebf3 100644 --- a/example/src/screens/BasicAnalytics.tsx +++ b/example/src/screens/BasicAnalytics.tsx @@ -27,6 +27,9 @@ export default function BasicAds() { customData5: 'Custom data field 5', }, }, + remoteControlConfig: { + isCastEnabled: false, + }, }); useFocusEffect( diff --git a/example/src/screens/BasicDrmPlayback.tsx b/example/src/screens/BasicDrmPlayback.tsx index a9634377..c441d386 100644 --- a/example/src/screens/BasicDrmPlayback.tsx +++ b/example/src/screens/BasicDrmPlayback.tsx @@ -111,7 +111,11 @@ const source: SourceConfig = { export default function BasicDrmPlayback() { useTVGestures(); - const player = usePlayer(); + const player = usePlayer({ + remoteControlConfig: { + isCastEnabled: false, + }, + }); useFocusEffect( useCallback(() => { diff --git a/example/src/screens/BasicFullscreenHandling.tsx b/example/src/screens/BasicFullscreenHandling.tsx index 88cc3ecc..a87d1e5e 100644 --- a/example/src/screens/BasicFullscreenHandling.tsx +++ b/example/src/screens/BasicFullscreenHandling.tsx @@ -66,7 +66,11 @@ export default function BasicFullscreenHandling({ }: BasicFullscreenHandlingProps) { useTVGestures(); - const player = usePlayer(); + const player = usePlayer({ + remoteControlConfig: { + isCastEnabled: false, + }, + }); const [fullscreenMode, setFullscreenMode] = useState(false); const fullscreenHandler = useRef( diff --git a/example/src/screens/BasicPictureInPicture.tsx b/example/src/screens/BasicPictureInPicture.tsx index 7496d68e..d4d69b56 100644 --- a/example/src/screens/BasicPictureInPicture.tsx +++ b/example/src/screens/BasicPictureInPicture.tsx @@ -22,6 +22,9 @@ export default function BasicPictureInPicture() { // Enable picture in picture UI option on player controls. isPictureInPictureEnabled: true, }, + remoteControlConfig: { + isCastEnabled: false, + }, }); useFocusEffect( diff --git a/example/src/screens/BasicPlayback.tsx b/example/src/screens/BasicPlayback.tsx index f7ec3304..027bad63 100644 --- a/example/src/screens/BasicPlayback.tsx +++ b/example/src/screens/BasicPlayback.tsx @@ -16,7 +16,11 @@ function prettyPrint(header: string, obj: any) { export default function BasicPlayback() { useTVGestures(); - const player = usePlayer(); + const player = usePlayer({ + remoteControlConfig: { + isCastEnabled: false, + }, + }); useFocusEffect( useCallback(() => { diff --git a/example/src/screens/Casting.tsx b/example/src/screens/Casting.tsx new file mode 100644 index 00000000..7846181c --- /dev/null +++ b/example/src/screens/Casting.tsx @@ -0,0 +1,101 @@ +import React, { useCallback } from 'react'; +import { View, StyleSheet, Platform } from 'react-native'; +import { useFocusEffect } from '@react-navigation/native'; +import { + Event, + usePlayer, + PlayerView, + SourceType, + BitmovinCastManager, + Source, +} from 'bitmovin-player-react-native'; + +function prettyPrint(header: string, obj: any) { + console.log(header, JSON.stringify(obj, null, 2)); +} + +export default function Casting() { + BitmovinCastManager.initialize(); + + if (Platform.OS === 'android') { + // Must be called in every activity on Android + BitmovinCastManager.updateContext(); + } + + const player = usePlayer(); + + useFocusEffect( + useCallback(() => { + const source = new Source({ + url: + Platform.OS === 'ios' + ? 'https://bitmovin-a.akamaihd.net/content/MI201109210084_1/m3u8s/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8' + : 'https://bitmovin-a.akamaihd.net/content/MI201109210084_1/mpds/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.mpd', + type: Platform.OS === 'ios' ? SourceType.HLS : SourceType.DASH, + title: 'Art of Motion', + poster: + 'https://bitmovin-a.akamaihd.net/content/MI201109210084_1/poster.jpg', + thumbnailTrack: + 'https://cdn.bitmovin.com/content/assets/art-of-motion-dash-hls-progressive/thumbnails/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.vtt', + metadata: { platform: Platform.OS }, + }); + + // Configure playing DASH source on Chromecast, even when casting from iOS. + source.remoteControl = { + castSourceConfig: { + url: 'https://bitmovin-a.akamaihd.net/content/MI201109210084_1/mpds/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.mpd', + type: SourceType.DASH, + title: 'Art of Motion', + }, + }; + player.loadSource(source); + return () => { + player.destroy(); + }; + }, [player]) + ); + + const onReady = useCallback((event: Event) => { + prettyPrint(`EVENT [${event.name}]`, event); + }, []); + + const onEvent = useCallback((event: Event) => { + prettyPrint(`EVENT [${event.name}]`, event); + }, []); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'black', + }, + player: { + flex: 1, + }, +}); diff --git a/example/src/screens/CustomHtmlUi.tsx b/example/src/screens/CustomHtmlUi.tsx index 70cd6756..ee948312 100644 --- a/example/src/screens/CustomHtmlUi.tsx +++ b/example/src/screens/CustomHtmlUi.tsx @@ -55,6 +55,9 @@ export default function CustomHtmlUi({ navigation }: CustomHtmlUiProps) { supplementalPlayerUiCss: 'https://cdn.bitmovin.com/player/ui/ui-customized-sample.css', }, + remoteControlConfig: { + isCastEnabled: false, + }, }); useFocusEffect( diff --git a/example/src/screens/CustomPlayback.tsx b/example/src/screens/CustomPlayback.tsx index 5b4ffb15..3c9bed6a 100644 --- a/example/src/screens/CustomPlayback.tsx +++ b/example/src/screens/CustomPlayback.tsx @@ -16,6 +16,9 @@ const CustomPlayback: React.FC = ({ route }) => { const player = usePlayer({ licenseKey: route.params?.licenseKey, + remoteControlConfig: { + isCastEnabled: false, + }, }); useFocusEffect( diff --git a/example/src/screens/LandscapeFullscreenHandling.tsx b/example/src/screens/LandscapeFullscreenHandling.tsx index 9b185bcb..5e027e3d 100644 --- a/example/src/screens/LandscapeFullscreenHandling.tsx +++ b/example/src/screens/LandscapeFullscreenHandling.tsx @@ -74,6 +74,9 @@ export default function LandscapeFullscreenHandling({ playbackConfig: { isAutoplayEnabled: true, }, + remoteControlConfig: { + isCastEnabled: false, + }, }); const [fullscreenMode, setFullscreenMode] = useState(true); diff --git a/example/src/screens/OfflinePlayback.tsx b/example/src/screens/OfflinePlayback.tsx index c3f0ae7f..3a4ba235 100644 --- a/example/src/screens/OfflinePlayback.tsx +++ b/example/src/screens/OfflinePlayback.tsx @@ -88,7 +88,11 @@ export default function OfflinePlayback() { prettyPrint(`EVENT`, event); }, []); - const player = usePlayer(); + const player = usePlayer({ + remoteControlConfig: { + isCastEnabled: false, + }, + }); useFocusEffect(useCallback(() => () => player.destroy(), [player])); diff --git a/example/src/screens/ProgrammaticTrackSelection.tsx b/example/src/screens/ProgrammaticTrackSelection.tsx index b1389d06..02976b10 100644 --- a/example/src/screens/ProgrammaticTrackSelection.tsx +++ b/example/src/screens/ProgrammaticTrackSelection.tsx @@ -32,7 +32,11 @@ const Separator = () => ; export default function ProgrammaticTrackSelection() { useTVGestures(); - const player = usePlayer(); + const player = usePlayer({ + remoteControlConfig: { + isCastEnabled: false, + }, + }); const [tracks, setTracks] = useState([]); useFocusEffect( diff --git a/example/src/screens/SubtitlePlayback.tsx b/example/src/screens/SubtitlePlayback.tsx index 6cc15889..bff9b0e8 100644 --- a/example/src/screens/SubtitlePlayback.tsx +++ b/example/src/screens/SubtitlePlayback.tsx @@ -17,7 +17,11 @@ function prettyPrint(header: string, obj: any) { export default function SubtitlePlayback() { useTVGestures(); - const player = usePlayer(); + const player = usePlayer({ + remoteControlConfig: { + isCastEnabled: false, + }, + }); useFocusEffect( useCallback(() => { diff --git a/example/src/screens/SystemUi.tsx b/example/src/screens/SystemUi.tsx index 4d50532b..30cbc2a6 100644 --- a/example/src/screens/SystemUi.tsx +++ b/example/src/screens/SystemUi.tsx @@ -22,6 +22,9 @@ export default function SystemUI() { styleConfig: { userInterfaceType: UserInterfaceType.System, }, + remoteControlConfig: { + isCastEnabled: false, + }, }); useFocusEffect( diff --git a/ios/BitmovinCastManagerModule.m b/ios/BitmovinCastManagerModule.m new file mode 100644 index 00000000..eaa02c22 --- /dev/null +++ b/ios/BitmovinCastManagerModule.m @@ -0,0 +1,14 @@ +#import + +@interface RCT_EXTERN_REMAP_MODULE(BitmovinCastManagerModule, BitmovinCastManagerModule, NSObject) + +RCT_EXTERN_METHOD( + isInitialized:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD( + initializeCastManager:(nullable id)config + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(sendMessage:(NSString *)message messageNamespace:(nullable NSString *)messageNamespace) + +@end diff --git a/ios/BitmovinCastManagerModule.swift b/ios/BitmovinCastManagerModule.swift new file mode 100644 index 00000000..f636c2f1 --- /dev/null +++ b/ios/BitmovinCastManagerModule.swift @@ -0,0 +1,83 @@ +import BitmovinPlayer + +@objc(BitmovinCastManagerModule) +class BitmovinCastManagerModule: NSObject, RCTBridgeModule { + /// React bridge reference. + @objc var bridge: RCTBridge! + + /// JS module name. + static func moduleName() -> String! { + "BitmovinCastManagerModule" + } + + /// Module requires main thread initialization. + static func requiresMainQueueSetup() -> Bool { + true + } + + /// Since most `BitmovinCastManagerModule` operations are UI related and need to be executed on the main thread, they are scheduled with `UIManager.addBlock`. + var methodQueue: DispatchQueue! { + bridge.uiManager.methodQueue + } + + /** + Initializes the BitmovinCastManager with the given options or with no options when none given. + */ + @objc(initializeCastManager:resolver:rejecter:) + func initializeCastManager( + _ config: Any?, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { +#if os(iOS) + bridge.uiManager.addUIBlock { _, _ in + if let config = config { + guard let options = RCTConvert.castManagerOptions(config) else { + reject("BitmovinCastManagerModule", "Could not deserialize BitmovinCastManagerOptions", nil) + return + } + BitmovinCastManager.initializeCasting(options: options) + resolve(nil) + } else { + BitmovinCastManager.initializeCasting() + resolve(nil) + } + } +#endif + } + + /** + Returns true if casting is already initialized. + - Parameter resolver: JS promise resolver. + - Parameter rejecter: JS promise rejecter. + */ + @objc(isInitialized:rejecter:) + func isInitialized( + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + bridge.uiManager.addUIBlock { _, _ in +#if os(iOS) + resolve(BitmovinCastManager.isInitialized()) +#else + resolve(false) +#endif + } + } + + /** + Sends the given message to the cast receiver on the provided namespace. + If no namespace is provided, the one returned by defaultChannel.protocolNamespace is used. + */ + @objc(sendMessage:messageNamespace:) + func sendMessage( + _ message: String, + messageNamespace: String? + ) { +#if os(iOS) + bridge.uiManager.addUIBlock { _, _ in + BitmovinCastManager.sharedInstance().sendMessage(message, withNamespace: messageNamespace) + } +#endif + } +} diff --git a/ios/DrmModule.swift b/ios/DrmModule.swift index 0cef2fd2..9229eabc 100644 --- a/ios/DrmModule.swift +++ b/ios/DrmModule.swift @@ -42,7 +42,7 @@ class DrmModule: NSObject, RCTBridgeModule { bridge.uiManager.addUIBlock { [weak self] _, _ in guard self?.drmConfigs[nativeId] == nil, - let fairplayConfig = RCTConvert.fairplayConfig(config) + let fairplayConfig = RCTConvert.drmConfig(config).fairplay else { return } diff --git a/ios/Event+JSON.swift b/ios/Event+JSON.swift index 204929f0..8dfc82b0 100644 --- a/ios/Event+JSON.swift +++ b/ios/Event+JSON.swift @@ -340,3 +340,25 @@ extension VideoDownloadQualityChangedEvent { ] } } + +extension CastStartedEvent { + func toJSON() -> [AnyHashable: Any] { + [ + "name": name, + "timestamp": timestamp, + "deviceName": deviceName + ] + } +} + +#if os(iOS) +extension CastWaitingForDeviceEvent { + func toJSON() -> [AnyHashable: Any] { + [ + "name": name, + "timestamp": timestamp, + "castPayload": RCTConvert.toJson(castPayload: castPayload) + ] + } +} +#endif diff --git a/ios/PlayerModule.m b/ios/PlayerModule.m index 131923df..575ee1d8 100644 --- a/ios/PlayerModule.m +++ b/ios/PlayerModule.m @@ -98,5 +98,15 @@ @interface RCT_EXTERN_REMAP_MODULE(PlayerModule, PlayerModule, NSObject) time:(nonnull NSNumber *)time resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD( + isCastAvailable:(NSString *)nativeId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD( + isCasting:(NSString *)nativeId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(castVideo:(NSString *)nativeId) +RCT_EXTERN_METHOD(castStop:(NSString *)nativeId) @end diff --git a/ios/PlayerModule.swift b/ios/PlayerModule.swift index 895f7afd..725e330e 100644 --- a/ios/PlayerModule.swift +++ b/ios/PlayerModule.swift @@ -45,10 +45,13 @@ class PlayerModule: NSObject, RCTBridgeModule { else { return } +#if os(iOS) + self?.setupRemoteControlConfig(playerConfig.remoteControlConfig) +#endif self?.players[nativeId] = PlayerFactory.create(playerConfig: playerConfig) } } - + /** Creates a new analytics enabled `Player` instance inside the internal players using the provided `config` and `analyticsConfig` object. - Parameter config: `PlayerConfig` object received from JS. @@ -65,6 +68,9 @@ class PlayerModule: NSObject, RCTBridgeModule { else { return } +#if os(iOS) + self?.setupRemoteControlConfig(playerConfig.remoteControlConfig) +#endif let defaultMetadata = RCTConvert.analyticsDefaultMetadataFromAnalyticsConfig(analyticsConfigJson) self?.players[nativeId] = PlayerFactory.create( playerConfig: playerConfig, @@ -607,4 +613,71 @@ class PlayerModule: NSObject, RCTBridgeModule { resolve(RCTConvert.toJson(thumbnail: self?.players[nativeId]?.thumbnail(forTime: time.doubleValue))) } } + + /** + Returns `true` if casting to another device (such as a ChromeCast) is available, otherwise false. + - Parameter nativeId: Target player id. + - Parameter resolver: JS promise resolver. + - Parameter rejecter: JS promise rejecter. + */ + @objc(isCastAvailable:resolver:rejecter:) + func isCastAvailable( + _ nativeId: NativeId, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + bridge.uiManager.addUIBlock { [weak self] _, _ in + resolve(self?.players[nativeId]?.isCastAvailable) + } + } + + /** + Returns `true` if the video is currently casted to a device and not played locally, or `false` if the video is played locally. + - Parameter nativeId: Target player id. + - Parameter resolver: JS promise resolver. + - Parameter rejecter: JS promise rejecter. + */ + @objc(isCasting:resolver:rejecter:) + func isCasting( + _ nativeId: NativeId, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + bridge.uiManager.addUIBlock { [weak self] _, _ in + resolve(self?.players[nativeId]?.isCasting) + } + } + + /** + Initiates casting the current video to a cast-compatible device. The user has to choose to which device it should be sent. + */ + @objc(castVideo:) + func castVideo(_ nativeId: NativeId) { + bridge.uiManager.addUIBlock { [weak self] _, _ in + self?.players[nativeId]?.castVideo() + } + } + + /** + Stops casting the current video if it is casting at the moment (i.e. `isCasting` returns `true`). + Has no effect if `isCasting` returns `false`. + */ + @objc(castStop:) + func castStop(_ nativeId: NativeId) { + bridge.uiManager.addUIBlock { [weak self] _, _ in + self?.players[nativeId]?.castStop() + } + } + + private func setupRemoteControlConfig(_ remoteControlConfig: RemoteControlConfig) { + remoteControlConfig.prepareSource = { [weak self] type, sourceConfig in + guard let sourceModule = self?.bridge[SourceModule.self], + let sourceNativeId = sourceModule.nativeId(where: { $0.sourceConfig === sourceConfig }), + let castSourceConfig = sourceModule.retrieveCastSourceConfig(sourceNativeId) else { + return nil + } + + return castSourceConfig + } + } } diff --git a/ios/RCTConvert+BitmovinPlayer.swift b/ios/RCTConvert+BitmovinPlayer.swift index 8201ddee..de636827 100644 --- a/ios/RCTConvert+BitmovinPlayer.swift +++ b/ios/RCTConvert+BitmovinPlayer.swift @@ -31,6 +31,11 @@ extension RCTConvert { if let adaptationConfig = RCTConvert.adaptationConfig(json["adaptationConfig"]) { playerConfig.adaptationConfig = adaptationConfig } +#if os(iOS) + if let remoteControlConfig = RCTConvert.remoteControlConfig(json["remoteControlConfig"]) { + playerConfig.remoteControlConfig = remoteControlConfig + } +#endif return playerConfig } @@ -225,7 +230,7 @@ extension RCTConvert { - Parameter json: JS object - Returns: The produced `SourceConfig` object */ - static func sourceConfig(_ json: Any?, drmConfig: FairplayConfig? = nil) -> SourceConfig? { + static func sourceConfig(_ json: Any?, drmConfig: DrmConfig? = nil) -> SourceConfig? { guard let json = json as? [String: Any?] else { return nil } @@ -266,7 +271,7 @@ extension RCTConvert { } return sourceConfig } - + /** Utility method to instantiate a `SourceOptions` from a JS object. - Parameter json: JS object @@ -332,33 +337,62 @@ extension RCTConvert { } } + /** + Utility method to get a `DrmConfig` from a JS object. + - Parameter json: JS object + - Returns: The generated `DrmConfig` object + */ + static func drmConfig(_ json: Any?) -> (fairplay: FairplayConfig?, widevine: WidevineConfig?) { + guard let json = json as? [String: Any?] else { + return (nil, nil) + } + return ( + fairplay: RCTConvert.fairplayConfig(json["fairplay"]), + widevine: RCTConvert.widevineConfig(json["widevine"]) + ) + } + /** Utility method to get a `FairplayConfig` from a JS object. - Parameter json: JS object - Returns: The generated `FairplayConfig` object */ static func fairplayConfig(_ json: Any?) -> FairplayConfig? { - guard - let json = json as? [String: Any?], - let fairplayJson = json["fairplay"] as? [String: Any?], - let licenseURL = fairplayJson["licenseUrl"] as? String, - let certificateURL = fairplayJson["certificateUrl"] as? String - else { + guard let json = json as? [String: Any?], + let licenseURL = json["licenseUrl"] as? String, + let certificateURL = json["certificateUrl"] as? String else { return nil } let fairplayConfig = FairplayConfig( license: URL(string: licenseURL), certificateURL: URL(string: certificateURL)! ) - if let licenseRequestHeaders = fairplayJson["licenseRequestHeaders"] as? [String: String] { + if let licenseRequestHeaders = json["licenseRequestHeaders"] as? [String: String] { fairplayConfig.licenseRequestHeaders = licenseRequestHeaders } - if let certificateRequestHeaders = fairplayJson["certificateRequestHeaders"] as? [String: String] { + if let certificateRequestHeaders = json["certificateRequestHeaders"] as? [String: String] { fairplayConfig.certificateRequestHeaders = certificateRequestHeaders } return fairplayConfig } + /** + Utility method to get a `WidevineConfig` from a JS object. + - Parameter json: JS object + - Returns: The generated `WidevineConfig` object + */ + static func widevineConfig(_ json: Any?) -> WidevineConfig? { + guard let json = json as? [String: Any?], + let licenseURL = json["licenseUrl"] as? String else { + return nil + } + let widevineConfig = WidevineConfig(license: URL(string: licenseURL)) + if let licenseRequestHeaders = json["httpHeaders"] as? [String: String] { + widevineConfig.licenseRequestHeaders = licenseRequestHeaders + } + return widevineConfig + } + /** Utility method to get a `ThumbnailTrack` instance from a JS object. - Parameter url: String. @@ -645,7 +679,7 @@ extension RCTConvert { } let randomizeUserId = json["randomizeUserId"] as? Bool let adTrackingDisabled = json["adTrackingDisabled"] as? Bool - + let config = AnalyticsConfig( licenseKey: key, randomizeUserId: randomizeUserId ?? false, @@ -653,7 +687,7 @@ extension RCTConvert { ) return config } - + static func analyticsDefaultMetadataFromAnalyticsConfig(_ json: Any?) -> DefaultMetadata? { guard let analyticsConfigJson = json as? [String: Any?], @@ -664,7 +698,7 @@ extension RCTConvert { let cdnProvider = json["cdnProvider"] as? String let customUserId = json["customUserId"] as? String let customData = analyticsCustomData(json) - + return DefaultMetadata( cdnProvider: cdnProvider, customUserId: customUserId, @@ -919,4 +953,84 @@ extension RCTConvert { "height": thumbnail.height, ] } + + /** + Utility method to instantiate a `RemoteControlConfig` from a JS object. + - Parameter json: JS object + - Returns: The produced `RemoteControlConfig` object + */ + static func remoteControlConfig(_ json: Any?) -> RemoteControlConfig? { + guard let json = json as? [String: Any?] else { + return nil + } + let remoteControlConfig = RemoteControlConfig() + if let receiverStylesheetUrl = RCTConvert.nsurl(json["receiverStylesheetUrl"]) { + remoteControlConfig.receiverStylesheetUrl = receiverStylesheetUrl + } + if let customReceiverConfig = json["customReceiverConfig"] as? [String: String] { + remoteControlConfig.customReceiverConfig = customReceiverConfig + } + if let isCastEnabled = json["isCastEnabled"] as? Bool { + remoteControlConfig.isCastEnabled = isCastEnabled + } + if let sendManifestRequestsWithCredentials = json["sendManifestRequestsWithCredentials"] as? Bool { + remoteControlConfig.sendManifestRequestsWithCredentials = sendManifestRequestsWithCredentials + } + if let sendSegmentRequestsWithCredentials = json["sendSegmentRequestsWithCredentials"] as? Bool { + remoteControlConfig.sendSegmentRequestsWithCredentials = sendSegmentRequestsWithCredentials + } + if let sendDrmLicenseRequestsWithCredentials = json["sendDrmLicenseRequestsWithCredentials"] as? Bool { + remoteControlConfig.sendDrmLicenseRequestsWithCredentials = sendDrmLicenseRequestsWithCredentials + } + return remoteControlConfig + } + +#if os(iOS) + /** + Utility method to instantiate a `BitmovinCastManagerOptions` from a JS object. + - Parameter json: JS object + - Returns: The produced `BitmovinCastManagerOptions` object + */ + static func castManagerOptions(_ json: Any?) -> BitmovinCastManagerOptions? { + guard let json = json as? [String: Any?] else { + return nil + } + + let options = BitmovinCastManagerOptions() + options.applicationId = json["applicationId"] as? String + options.messageNamespace = json["messageNamespace"] as? String + return options + } + + /** + Utility method to compute a JS value from an `CastPayload` object. + - Parameter castPayload `CastPayload` object to be converted. + - Returns: The produced JS object. + */ + static func toJson(castPayload: CastPayload) -> [String: Any?] { + return [ + "currentTime": castPayload.currentTime, + "deviceName": castPayload.deviceName, + "type": castPayload.type, + ] + } + + /** + Utility method to instantiate a `SourceRemoteControlConfig` from a JS object. + - Parameter json: JS object + - Returns: The produced `SourceRemoteControlConfig` object + */ + static func sourceRemoteControlConfig(_ json: Any?) -> SourceRemoteControlConfig? { + guard let json = json as? [String: Any?], + let castSourceConfigJson = json["castSourceConfig"] as? [String: Any?] else { + return nil + } + return SourceRemoteControlConfig( + castSourceConfig: RCTConvert.sourceConfig( + json["castSourceConfig"], + drmConfig: RCTConvert.drmConfig(castSourceConfigJson["drmConfig"]).widevine + ) + ) + } +#endif } diff --git a/ios/RNPlayerView+PlayerListener.swift b/ios/RNPlayerView+PlayerListener.swift index 6e485bb6..0918a05b 100644 --- a/ios/RNPlayerView+PlayerListener.swift +++ b/ios/RNPlayerView+PlayerListener.swift @@ -168,4 +168,41 @@ extension RNPlayerView: PlayerListener { func onVideoPlaybackQualityChanged(_ event: VideoDownloadQualityChangedEvent, player: Player) { onVideoPlaybackQualityChanged?(event.toJSON()) } + +#if os(iOS) + func onCastAvailable(_ event: CastAvailableEvent, player: Player) { + onCastAvailable?(event.toJSON()) + } + + func onCastPaused(_ event: CastPausedEvent, player: Player) { + onCastPaused?(event.toJSON()) + } + + func onCastPlaybackFinished(_ event: CastPlaybackFinishedEvent, player: Player) { + onCastPlaybackFinished?(event.toJSON()) + } + + func onCastPlaying(_ event: CastPlayingEvent, player: Player) { + onCastPlaying?(event.toJSON()) + } + + func onCastStarted(_ event: CastStartedEvent, player: Player) { + onCastStarted?(event.toJSON()) + } + + func onCastStart(_ event: CastStartEvent, player: Player) { + onCastStart?(event.toJSON()) + } + + func onCastStopped(_ event: CastStoppedEvent, player: Player) { + onCastStopped?(event.toJSON()) + } + + func onCastTimeUpdated(_ event: CastTimeUpdatedEvent, player: Player) { + onCastTimeUpdated?(event.toJSON()) + } + func onCastWaitingForDevice(_ event: CastWaitingForDeviceEvent, player: Player) { + onCastWaitingForDevice?(event.toJSON()) + } +#endif } diff --git a/ios/RNPlayerView.swift b/ios/RNPlayerView.swift index dc1d4b94..2112c33d 100644 --- a/ios/RNPlayerView.swift +++ b/ios/RNPlayerView.swift @@ -53,6 +53,15 @@ class RNPlayerView: UIView { @objc var onFullscreenDisabled: RCTBubblingEventBlock? @objc var onFullscreenEnter: RCTBubblingEventBlock? @objc var onFullscreenExit: RCTBubblingEventBlock? + @objc var onCastAvailable: RCTBubblingEventBlock? + @objc var onCastPaused: RCTBubblingEventBlock? + @objc var onCastPlaybackFinished: RCTBubblingEventBlock? + @objc var onCastPlaying: RCTBubblingEventBlock? + @objc var onCastStarted: RCTBubblingEventBlock? + @objc var onCastStart: RCTBubblingEventBlock? + @objc var onCastStopped: RCTBubblingEventBlock? + @objc var onCastTimeUpdated: RCTBubblingEventBlock? + @objc var onCastWaitingForDevice: RCTBubblingEventBlock? /// The `PlayerView` subview. var playerView: PlayerView? { diff --git a/ios/RNPlayerViewManager.m b/ios/RNPlayerViewManager.m index 18f8f109..3fec7f83 100644 --- a/ios/RNPlayerViewManager.m +++ b/ios/RNPlayerViewManager.m @@ -52,6 +52,15 @@ @interface RCT_EXTERN_REMAP_MODULE(NativePlayerView, RNPlayerViewManager, RCTVie RCT_EXPORT_VIEW_PROPERTY(onFullscreenDisabled, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(onFullscreenEnter, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(onFullscreenExit, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onCastAvailable, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onCastPaused, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onCastPlaybackFinished, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onCastPlaying, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onCastStarted, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onCastStart, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onCastStopped, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onCastTimeUpdated, RCTBubblingEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onCastWaitingForDevice, RCTBubblingEventBlock) RCT_EXTERN_METHOD(attachPlayer:(nonnull NSNumber *)viewId playerId:(NSString *)playerId playerConfig:(nullable NSDictionary *)playerConfig) RCT_EXTERN_METHOD(attachFullscreenBridge:(nonnull NSNumber *)viewId fullscreenBridgeId:(NSString *)fullscreenBridgeId) diff --git a/ios/SourceModule.m b/ios/SourceModule.m index 0f07e7eb..7d5d4024 100644 --- a/ios/SourceModule.m +++ b/ios/SourceModule.m @@ -2,8 +2,15 @@ @interface RCT_EXTERN_REMAP_MODULE(SourceModule, SourceModule, NSObject) -RCT_EXTERN_METHOD(initWithConfig:(NSString *)nativeId drmNativeId:(NSString *)drmNativeId config:(nullable id)config) -RCT_EXTERN_METHOD(initWithAnalyticsConfig:(NSString *)nativeId drmNativeId:(NSString *)drmNativeId config:(nullable id)config analyticsSourceMetadata:(nullable id)analyticsSourceMetadata) +RCT_EXTERN_METHOD(initWithConfig:(NSString *)nativeId + drmNativeId:(NSString *)drmNativeId + config:(nullable id)config + sourceRemoteControlConfig:(id)remotePlayback) +RCT_EXTERN_METHOD(initWithAnalyticsConfig:(NSString *)nativeId + drmNativeId:(NSString *)drmNativeId + config:(nullable id)config + sourceRemoteControlConfig:(id)remotePlayback + analyticsSourceMetadata:(nullable id)analyticsSourceMetadata) RCT_EXTERN_METHOD(destroy:(NSString *)nativeId) RCT_EXTERN_METHOD( isAttachedToPlayer:(NSString *)nativeId diff --git a/ios/SourceModule.swift b/ios/SourceModule.swift index 1bd3b735..d09d900d 100644 --- a/ios/SourceModule.swift +++ b/ios/SourceModule.swift @@ -8,6 +8,9 @@ class SourceModule: NSObject, RCTBridgeModule { /// In-memory mapping from `nativeId`s to `Source` instances. private var sources: Registry = [:] + /// In-memory mapping from `nativeId`s to `SourceConfig` instances for casting. + private var castSourceConfigs: Registry = [:] + /// JS module name. static func moduleName() -> String! { "SourceModule" @@ -32,31 +35,55 @@ class SourceModule: NSObject, RCTBridgeModule { sources[nativeId] } + // Finds `NativeId` based on predicate ran on `Source` instances + func nativeId(where predicate: (Source) -> Bool) -> NativeId? { + sources.first { _, value in + predicate(value) + }?.key + } + + // Fetches cast-specific `SourceConfig` by `NativeId` if exists + func retrieveCastSourceConfig(_ nativeId: NativeId) -> SourceConfig? { + castSourceConfigs[nativeId] + } + /** Creates a new `Source` instance inside the internal sources using the provided `config` and `analyticsSourceMetadata` object and an optionally initialized DRM configuration ID. - Parameter nativeId: ID to be associated with the `Source` instance. - Parameter drmNativeId: ID of the DRM config object to use. - Parameter config: `SourceConfig` object received from JS. - Parameter analyticsSourceMetadata: `SourceMetadata` object received from JS. + - Parameter sourceRemoteControlConfig: `SourceRemoteControlConfig` object received from JS. */ - @objc(initWithAnalyticsConfig:drmNativeId:config:analyticsSourceMetadata:) - func initWithAnalyticsConfig(_ nativeId: NativeId, drmNativeId: NativeId?, config: Any?, analyticsSourceMetadata: Any?) { + @objc(initWithAnalyticsConfig:drmNativeId:config:sourceRemoteControlConfig:analyticsSourceMetadata:) + func initWithAnalyticsConfig( + _ nativeId: NativeId, + drmNativeId: NativeId?, + config: Any?, + sourceRemoteControlConfig: Any?, + analyticsSourceMetadata: Any? + ) { bridge.uiManager.addUIBlock { [weak self] _, _ in - let fairplayConfig: FairplayConfig? + let drmConfig: DrmConfig? if let drmNativeId = drmNativeId { - fairplayConfig = self?.getDrmModule()?.retrieve(drmNativeId) + drmConfig = self?.getDrmModule()?.retrieve(drmNativeId) } else { - fairplayConfig = nil + drmConfig = nil } guard self?.sources[nativeId] == nil, - let sourceConfig = RCTConvert.sourceConfig(config, drmConfig: fairplayConfig), + let sourceConfig = RCTConvert.sourceConfig(config, drmConfig: drmConfig), let sourceMetadata = RCTConvert.analyticsSourceMetadata(analyticsSourceMetadata) else { return } self?.sources[nativeId] = SourceFactory.create(from: sourceConfig, sourceMetadata: sourceMetadata) +#if os(iOS) + if let remoteConfig = RCTConvert.sourceRemoteControlConfig(sourceRemoteControlConfig){ + self?.castSourceConfigs[nativeId] = remoteConfig.castSourceConfig + } +#endif } } @@ -65,24 +92,35 @@ class SourceModule: NSObject, RCTBridgeModule { - Parameter nativeId: ID to be associated with the `Source` instance. - Parameter drmNativeId: ID of the DRM config object to use. - Parameter config: `SourceConfig` object received from JS. + - Parameter sourceRemoteControlConfig: `SourceRemoteControlConfig` object received from JS. */ - @objc(initWithConfig:drmNativeId:config:) - func initWithConfig(_ nativeId: NativeId, drmNativeId: NativeId?, config: Any?) { + @objc(initWithConfig:drmNativeId:config:sourceRemoteControlConfig:) + func initWithConfig( + _ nativeId: NativeId, + drmNativeId: NativeId?, + config: Any?, + sourceRemoteControlConfig: Any? + ) { bridge.uiManager.addUIBlock { [weak self] _, _ in - let fairplayConfig: FairplayConfig? + let drmConfig: DrmConfig? if let drmNativeId = drmNativeId { - fairplayConfig = self?.getDrmModule()?.retrieve(drmNativeId) + drmConfig = self?.getDrmModule()?.retrieve(drmNativeId) } else { - fairplayConfig = nil + drmConfig = nil } guard self?.sources[nativeId] == nil, - let sourceConfig = RCTConvert.sourceConfig(config, drmConfig: fairplayConfig) + let sourceConfig = RCTConvert.sourceConfig(config, drmConfig: drmConfig) else { return } self?.sources[nativeId] = SourceFactory.create(from: sourceConfig) +#if os(iOS) + if let remoteConfig = RCTConvert.sourceRemoteControlConfig(sourceRemoteControlConfig) { + self?.castSourceConfigs[nativeId] = remoteConfig.castSourceConfig + } +#endif } } @@ -98,6 +136,7 @@ class SourceModule: NSObject, RCTBridgeModule { @objc(destroy:) func destroy(_ nativeId: NativeId) { sources.removeValue(forKey: nativeId) + castSourceConfigs.removeValue(forKey: nativeId) } /** @@ -219,3 +258,7 @@ class SourceModule: NSObject, RCTBridgeModule { } } } + +internal struct SourceRemoteControlConfig { + let castSourceConfig: SourceConfig? +} diff --git a/src/bitmovinCastManager.ts b/src/bitmovinCastManager.ts new file mode 100644 index 00000000..196821c7 --- /dev/null +++ b/src/bitmovinCastManager.ts @@ -0,0 +1,87 @@ +import { NativeModules, Platform } from 'react-native'; + +const BitmovinCastManagerModule = NativeModules.BitmovinCastManagerModule; + +/** + * The options to be used for initializing `BitmovinCastManager` + * @platform Android, iOS + */ +export interface BitmovinCastManagerOptions { + /** + * ID of receiver application. + * Using `null` value will result in using the default application ID + */ + applicationId?: string | null; + /** + * A custom message namespace to be used for communication between sender and receiver. + * Using `null` value will result in using the default message namespace + */ + messageNamespace?: string | null; +} + +/** + * Singleton providing access to GoogleCast related features. + * The `BitmovinCastManager` needs to be initialized by calling `BitmovinCastManager.initialize` + * before `Player` creation to enable casting features. + * + * @platform Android, iOS + */ +export const BitmovinCastManager = { + /** + * Returns whether the `BitmovinCastManager` is initialized. + * @returns A promise that resolves with a boolean indicating whether the `BitmovinCastManager` is initialized + */ + isInitialized: async (): Promise => { + if (Platform.OS === 'ios' && Platform.isTV) { + return false; + } + return BitmovinCastManagerModule.isInitialized(); + }, + + /** + * Initialize `BitmovinCastManager` based on the provided `BitmovinCastManagerOptions`. + * This method needs to be called before `Player` creation to enable casting features. + * If no options are provided, the default options will be used. + * + * IMPORTANT: This should only be called when the Google Cast SDK is available in the application. + * + * @param options The options to be used for initializing `BitmovinCastManager` + * @returns A promise that resolves when the `BitmovinCastManager` was initialized successfully + */ + initialize: async ( + options: BitmovinCastManagerOptions | null = null + ): Promise => { + if (Platform.OS === 'ios' && Platform.isTV) { + return Promise.resolve(); + } + return BitmovinCastManagerModule.initializeCastManager(options); + }, + + /** + * Must be called in every Android Activity to update the context to the current one. + * Make sure to call this method on every Android Activity switch. + * + * @returns A promise that resolves when the context was updated successfully + * @platform Android + */ + updateContext: async (): Promise => { + if (Platform.OS === 'ios') { + return Promise.resolve(); + } + return BitmovinCastManagerModule.updateContext(); + }, + + /** + * Sends the given message to the cast receiver. + * + * @param message The message to be sent + * @param messageNamespace The message namespace to be used, in case of null the default message namespace will be used + * @returns A promise that resolves when the message was sent successfully + */ + sendMessage: (message: String, messageNamespace: String | null = null) => { + if (Platform.OS === 'ios' && Platform.isTV) { + return Promise.resolve(); + } + return BitmovinCastManagerModule.sendMessage(message, messageNamespace); + }, +}; diff --git a/src/components/PlayerView/events.ts b/src/components/PlayerView/events.ts index 2ebcb930..d8024da5 100644 --- a/src/components/PlayerView/events.ts +++ b/src/components/PlayerView/events.ts @@ -11,6 +11,15 @@ import { AdScheduledEvent, AdSkippedEvent, AdStartedEvent, + CastAvailableEvent, + CastPausedEvent, + CastPlaybackFinishedEvent, + CastPlayingEvent, + CastStartedEvent, + CastStartEvent, + CastStoppedEvent, + CastTimeUpdatedEvent, + CastWaitingForDeviceEvent, DestroyEvent, Event, FullscreenEnabledEvent, @@ -69,6 +78,15 @@ interface EventProps { onAdScheduled: AdScheduledEvent; onAdSkipped: AdSkippedEvent; onAdStarted: AdStartedEvent; + onCastAvailable: CastAvailableEvent; + onCastPaused: CastPausedEvent; + onCastPlaybackFinished: CastPlaybackFinishedEvent; + onCastPlaying: CastPlayingEvent; + onCastStarted: CastStartedEvent; + onCastStart: CastStartEvent; + onCastStopped: CastStoppedEvent; + onCastTimeUpdated: CastTimeUpdatedEvent; + onCastWaitingForDevice: CastWaitingForDeviceEvent; onDestroy: DestroyEvent; onEvent: Event; onFullscreenEnabled: FullscreenEnabledEvent; diff --git a/src/components/PlayerView/index.tsx b/src/components/PlayerView/index.tsx index 5fa509e3..f6519daf 100644 --- a/src/components/PlayerView/index.tsx +++ b/src/components/PlayerView/index.tsx @@ -185,6 +185,15 @@ export function PlayerView({ onAdScheduled={proxy(props.onAdScheduled)} onAdSkipped={proxy(props.onAdSkipped)} onAdStarted={proxy(props.onAdStarted)} + onCastAvailable={proxy(props.onCastAvailable)} + onCastPaused={proxy(props.onCastPaused)} + onCastPlaybackFinished={proxy(props.onCastPlaybackFinished)} + onCastPlaying={proxy(props.onCastPlaying)} + onCastStarted={proxy(props.onCastStarted)} + onCastStart={proxy(props.onCastStart)} + onCastStopped={proxy(props.onCastStopped)} + onCastTimeUpdated={proxy(props.onCastTimeUpdated)} + onCastWaitingForDevice={proxy(props.onCastWaitingForDevice)} onDestroy={proxy(props.onDestroy)} onEvent={proxy(props.onEvent)} onFullscreenEnabled={proxy(props.onFullscreenEnabled)} diff --git a/src/drm/index.ts b/src/drm/index.ts index 6b054e3d..723ea01f 100644 --- a/src/drm/index.ts +++ b/src/drm/index.ts @@ -14,11 +14,15 @@ const DrmModule = NativeModules.DrmModule; */ export interface DrmConfig extends NativeInstanceConfig { /** - * FairPlay specific configuration. Only applicable for iOS. + * FairPlay specific configuration. + * + * @platform iOS */ fairplay?: FairplayConfig; /** - * Widevine specific configuration. Only applicable for Android. + * Widevine specific configuration. + * + * @platform Android, iOS (only for casting). */ widevine?: WidevineConfig; } diff --git a/src/drm/widevineConfig.ts b/src/drm/widevineConfig.ts index efdf3f30..5b409e6e 100644 --- a/src/drm/widevineConfig.ts +++ b/src/drm/widevineConfig.ts @@ -1,6 +1,6 @@ /** * Represents a Widevine Streaming DRM config. - * Android only. + * @platform Android, iOS (only for casting). */ export interface WidevineConfig { /** @@ -10,7 +10,7 @@ export interface WidevineConfig { /** * A map containing the HTTP request headers, or null. */ - httpHeaders: Record; + httpHeaders?: Record; /** * A block to prepare the data which is sent as the body of the POST license request. * As many DRM providers expect different, vendor-specific messages, this can be done using @@ -19,6 +19,8 @@ export interface WidevineConfig { * Note that both the passed `message` data and this block return value should be a Base64 string. * So use whatever solution suits you best to handle Base64 in React Native. * + * @platform Android + * * @param message - Base64 encoded message data. * @returns The processed Base64 encoded message. */ @@ -31,12 +33,16 @@ export interface WidevineConfig { * Note that both the passed `license` data and this block return value should be a Base64 string. * So use whatever solution suits you best to handle Base64 in React Native. * + * @platform Android + * * @param license - Base64 encoded license data. * @returns The processed Base64 encoded license. */ prepareLicense?: (license: string) => string; /** * Set widevine's preferred security level. + * + * @platform Android */ preferredSecurityLevel?: string; /** @@ -44,6 +50,8 @@ export interface WidevineConfig { * This allows DRM sessions to be reused over several different source items with the same DRM configuration as well * as the same DRM scheme information. * Default: `false` + * + * @platform Android */ - shouldKeepDrmSessionsAlive: boolean; + shouldKeepDrmSessionsAlive?: boolean; } diff --git a/src/events.ts b/src/events.ts index 4e866d1e..51a85072 100644 --- a/src/events.ts +++ b/src/events.ts @@ -554,3 +554,83 @@ export interface VideoPlaybackQualityChangedEvent extends Event { */ oldVideoQuality: VideoQuality; } + +/** + * Emitted when casting to a cast-compatible device is available. + */ +export interface CastAvailableEvent extends Event {} + +/** + * Emitted when the playback on a cast-compatible device was paused. + * + * On Android `PausedEvent` is also emitted while casting. + */ +export interface CastPausedEvent extends Event {} + +/** + * Emitted when the playback on a cast-compatible device has finished. + * + * On Android `PlaybackFinishedEvent` is also emitted while casting. + */ +export interface CastPlaybackFinishedEvent extends Event {} + +/** + * Emitted when playback on a cast-compatible device has started. + * + * On Android `PlayingEvent` is also emitted while casting. + */ +export interface CastPlayingEvent extends Event {} + +/** + * Emitted when the cast app is launched successfully. + */ +export interface CastStartedEvent extends Event { + /** + * The name of the cast device on which the app was launched. + */ + deviceName: string | null; +} + +/** + * Emitted when casting is initiated, but the user still needs to choose which device should be used. + */ +export interface CastStartEvent extends Event {} + +/** + * Emitted when casting to a cast-compatible device is stopped. + */ +export interface CastStoppedEvent extends Event {} + +/** + * Emitted when the time update from the currently used cast-compatible device is received. + */ +export interface CastTimeUpdatedEvent extends Event {} + +/** + * Contains information for the `CastWaitingForDeviceEvent`. + */ +export interface CastPayload { + /** + * The current time in seconds. + */ + currentTime: number; + /** + * The name of the chosen cast device. + */ + deviceName: string | null; + /** + * The type of the payload (always "cast"). + */ + type: string; +} + +/** + * Emitted when a cast-compatible device has been chosen and the player is waiting for the device to get ready for + * playback. + */ +export interface CastWaitingForDeviceEvent extends Event { + /** + * The `CastPayload` object for the event + */ + castPayload: CastPayload; +} diff --git a/src/index.ts b/src/index.ts index 9cbb0829..540f5089 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,3 +13,5 @@ export * from './styleConfig'; export * from './ui'; export * from './offline'; export * from './thumbnail'; +export * from './remoteControlConfig'; +export * from './bitmovinCastManager'; diff --git a/src/player.ts b/src/player.ts index 7b9223fe..0411a47b 100644 --- a/src/player.ts +++ b/src/player.ts @@ -11,6 +11,7 @@ import { AdaptationConfig } from './adaptationConfig'; import { OfflineContentManager, OfflineSourceOptions } from './offline'; import { Thumbnail } from './thumbnail'; import { AnalyticsApi } from './analytics/player'; +import { RemoteControlConfig } from './remoteControlConfig'; const PlayerModule = NativeModules.PlayerModule; @@ -61,6 +62,10 @@ export interface PlayerConfig extends NativeInstanceConfig { * Configures adaptation logic. */ adaptationConfig?: AdaptationConfig; + /** + * Configures remote playback functionality. + */ + remoteControlConfig?: RemoteControlConfig; } /** @@ -491,4 +496,42 @@ export class Player extends NativeInstance { getThumbnail = async (time: number): Promise => { return PlayerModule.getThumbnail(this.nativeId, time); }; + + /** + * Whether casting to a cast-compatible remote device is available. `CastAvailableEvent` signals when + * casting becomes available. + * + * @platform iOS, Android + */ + isCastAvailable = async (): Promise => { + return PlayerModule.isCastAvailable(this.nativeId); + }; + + /** + * Whether video is currently being casted to a remote device and not played locally. + * + * @platform iOS, Android + */ + isCasting = async (): Promise => { + return PlayerModule.isCasting(this.nativeId); + }; + + /** + * Initiates casting the current video to a cast-compatible remote device. The user has to choose to which device it + * should be sent. + * + * @platform iOS, Android + */ + castVideo = () => { + PlayerModule.castVideo(this.nativeId); + }; + + /** + * Stops casting the current video. Has no effect if `isCasting` is false. + * + * @platform iOS, Android + */ + castStop = () => { + PlayerModule.castStop(this.nativeId); + }; } diff --git a/src/remoteControlConfig.ts b/src/remoteControlConfig.ts new file mode 100644 index 00000000..4e337f45 --- /dev/null +++ b/src/remoteControlConfig.ts @@ -0,0 +1,37 @@ +/** + * Configures remote playback behavior. + */ +export interface RemoteControlConfig { + /** + * A URL to a CSS file the receiver app loads to style the receiver app. + * Default value is `null`, indicating that the default CSS of the receiver app will be used. + */ + receiverStylesheetUrl?: string | null; + /** + * A Map containing custom configuration values that are sent to the remote control receiver. + * Default value is an empty map. + */ + customReceiverConfig?: Record; + /** + * Whether casting is enabled. + * Default value is `true`. + * + * Has no effect if the `BitmovinCastManager` is not initialized before the `Player` is created with this configuration. + */ + isCastEnabled?: boolean; + /** + * Indicates whether cookies and credentials will be sent along manifest requests on the cast receiver + * Default value is `false`. + */ + sendManifestRequestsWithCredentials?: boolean; + /** + * Indicates whether cookies and credentials will be sent along segment requests on the cast receiver + * Default value is `false`. + */ + sendSegmentRequestsWithCredentials?: boolean; + /** + * Indicates whether cookies and credentials will be sent along DRM licence requests on the cast receiver + * Default value is `false`. + */ + sendDrmLicenseRequestsWithCredentials?: boolean; +} diff --git a/src/source.ts b/src/source.ts index 156ade8f..83536e45 100644 --- a/src/source.ts +++ b/src/source.ts @@ -136,6 +136,21 @@ export interface SourceConfig extends NativeInstanceConfig { analyticsSourceMetadata?: SourceMetadata; } +/** + * The remote control config for a source. + * @platform iOS + */ +export interface SourceRemoteControlConfig { + /** + * The `SourceConfig` for casting. + * Enables to play different content when casting. + * This can be useful when the remote playback device supports different streaming formats, + * DRM systems, etc. than the local device. + * If not set, the local source config will be used for casting. + */ + castSourceConfig?: SourceConfig | null; +} + /** * Represents audio and video content that can be loaded into a player. */ @@ -144,6 +159,13 @@ export class Source extends NativeInstance { * The native DRM config reference of this source. */ drm?: Drm; + /** + * The remote control config for this source. + * This is only supported on iOS. + * + * @platform iOS + */ + remoteControl: SourceRemoteControlConfig | null = null; /** * Whether the native `Source` object has been created. */ @@ -168,13 +190,15 @@ export class Source extends NativeInstance { this.nativeId, this.drm?.nativeId, this.config, + this.remoteControl, sourceMetadata ); } else { SourceModule.initWithConfig( this.nativeId, this.drm?.nativeId, - this.config + this.config, + this.remoteControl ); } this.isInitialized = true;