diff --git a/CHANGELOG.md b/CHANGELOG.md index 416d06e9..3463add1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [Unreleased] + +### Added + +- `MediaControlConfig` to configure the media control information for the application. When `isEnabled` is `true`, the current media information will be shown on the lock-screen, in notifications, and within the control center +- Android: `playerConfig.playbackConfig.isBackgroundPlaybackEnabled` to support background playback + +### Changed + +- Update Bitmovin's native Android SDK version to `3.91.0` + +### Deprecated + +- `TweaksConfig.updatesNowPlayingInfoCenter` in favor of `MediaControlConfig.isEnabled` + ## [0.30.0] - 2024-10-31 ### Changed diff --git a/android/build.gradle b/android/build.gradle index 46980e61..37d586f7 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -105,5 +105,6 @@ dependencies { // Bitmovin implementation 'com.google.ads.interactivemedia.v3:interactivemedia:3.33.0' implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1' - implementation 'com.bitmovin.player:player:3.90.0+jason' + implementation 'com.bitmovin.player:player:3.91.0+jason' + implementation 'com.bitmovin.player:player-media-session:3.91.0+jason' } diff --git a/android/src/main/java/com/bitmovin/player/reactnative/MediaSessionPlaybackManager.kt b/android/src/main/java/com/bitmovin/player/reactnative/MediaSessionPlaybackManager.kt new file mode 100644 index 00000000..15de7745 --- /dev/null +++ b/android/src/main/java/com/bitmovin/player/reactnative/MediaSessionPlaybackManager.kt @@ -0,0 +1,50 @@ +package com.bitmovin.player.reactnative + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import com.bitmovin.player.api.Player +import com.bitmovin.player.reactnative.extensions.playerModule +import com.bitmovin.player.reactnative.services.MediaSessionPlaybackService +import com.facebook.react.bridge.* + +class MediaSessionPlaybackManager(val context: ReactApplicationContext) { + private var serviceBinder: MediaSessionPlaybackService.ServiceBinder? = null + private lateinit var playerId: NativeId + val player: Player? + get() = serviceBinder?.player + + inner class MediaSessionPlaybackServiceConnection : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = service as MediaSessionPlaybackService.ServiceBinder + serviceBinder = binder + binder.player = getPlayer() + } + + override fun onServiceDisconnected(name: ComponentName) { + destroy(playerId) + } + } + + fun setupMediaSessionPlayback(playerId: NativeId) { + this.playerId = playerId + + val intent = Intent(context, MediaSessionPlaybackService::class.java) + intent.action = Intent.ACTION_MEDIA_BUTTON + val connection: ServiceConnection = MediaSessionPlaybackServiceConnection() + context.bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + + fun destroy(nativeId: NativeId) { + if (nativeId != playerId) { return } + serviceBinder?.player = null + serviceBinder = null + } + + private fun getPlayer( + nativeId: NativeId = playerId, + playerModule: PlayerModule? = context.playerModule, + ): Player = playerModule?.getPlayerOrNull(nativeId) ?: throw IllegalArgumentException("Invalid PlayerId $nativeId") +} 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 7922fb4c..b4a45185 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/PlayerModule.kt @@ -10,6 +10,7 @@ import com.bitmovin.player.reactnative.converter.toAdItem import com.bitmovin.player.reactnative.converter.toAnalyticsConfig import com.bitmovin.player.reactnative.converter.toAnalyticsDefaultMetadata import com.bitmovin.player.reactnative.converter.toJson +import com.bitmovin.player.reactnative.converter.toMediaControlConfig import com.bitmovin.player.reactnative.converter.toPlayerConfig import com.bitmovin.player.reactnative.extensions.mapToReactArray import com.facebook.react.bridge.* @@ -25,6 +26,8 @@ class PlayerModule(context: ReactApplicationContext) : BitmovinBaseModule(contex */ private val players: Registry = mutableMapOf() + val mediaSessionPlaybackManager = MediaSessionPlaybackManager(context) + /** * JS exported module name. */ @@ -74,6 +77,8 @@ class PlayerModule(context: ReactApplicationContext) : BitmovinBaseModule(contex val playerConfig = playerConfigJson?.toPlayerConfig() ?: PlayerConfig() val analyticsConfig = analyticsConfigJson?.toAnalyticsConfig() val defaultMetadata = analyticsConfigJson?.getMap("defaultMetadata")?.toAnalyticsDefaultMetadata() + val enableMediaSession = playerConfigJson?.getMap("mediaControlConfig") + ?.toMediaControlConfig()?.isEnabled ?: true val networkConfig = networkNativeId?.let { networkModule.getConfig(it) } if (networkConfig != null) { @@ -90,6 +95,12 @@ class PlayerModule(context: ReactApplicationContext) : BitmovinBaseModule(contex defaultMetadata = defaultMetadata ?: DefaultMetadata(), ) } + + if (enableMediaSession) { + promise.unit.resolveOnUiThread { + mediaSessionPlaybackManager.setupMediaSessionPlayback(nativeId) + } + } } /** @@ -211,6 +222,7 @@ class PlayerModule(context: ReactApplicationContext) : BitmovinBaseModule(contex */ @ReactMethod fun destroy(nativeId: NativeId, promise: Promise) { + mediaSessionPlaybackManager.destroy(nativeId) promise.unit.resolveOnUiThreadWithPlayer(nativeId) { destroy() players.remove(nativeId) 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 647cf14c..db019681 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerView.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerView.kt @@ -16,6 +16,7 @@ import com.bitmovin.player.api.event.SourceEvent import com.bitmovin.player.api.ui.PlayerViewConfig import com.bitmovin.player.api.ui.StyleConfig import com.bitmovin.player.reactnative.converter.toJson +import com.bitmovin.player.reactnative.extensions.playerModule import com.facebook.react.ReactActivity import com.facebook.react.bridge.* import com.facebook.react.uimanager.events.RCTEventEmitter @@ -102,8 +103,23 @@ class RNPlayerView( private val activityLifecycle = (context.currentActivity as? ReactActivity)?.lifecycle ?: error("Trying to create an instance of ${this::class.simpleName} while not attached to a ReactActivity") + /** + * Relays the provided set of events, emitted by the player, together with the associated name + * to the `eventOutput` callback. + */ + private var playerEventRelay: EventRelay = EventRelay( + EVENT_CLASS_TO_REACT_NATIVE_NAME_MAPPING, + ::emitEventFromPlayer, + ) + + internal var enableBackgroundPlayback: Boolean = false + var playerInMediaSessionService: Player? = null + private val activityLifecycleObserver = object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) { + if (playerInMediaSessionService != null) { + playerView?.player = playerInMediaSessionService + } playerView?.onStart() } @@ -116,10 +132,28 @@ class RNPlayerView( } override fun onStop(owner: LifecycleOwner) { + removePlayerForBackgroundPlayback() playerView?.onStop() } override fun onDestroy(owner: LifecycleOwner) = dispose() + + // When background playback is enabled, + // remove player from view so it does not get paused when entering background + private fun removePlayerForBackgroundPlayback() { + playerInMediaSessionService = null + val player = playerView?.player ?: return + + if (!enableBackgroundPlayback) { + return + } + if (context.playerModule?.mediaSessionPlaybackManager?.player != player) { + return + } + + playerInMediaSessionService = player + playerView?.player = null + } } init { @@ -133,15 +167,6 @@ class RNPlayerView( activityLifecycle.addObserver(activityLifecycleObserver) } - /** - * Relays the provided set of events, emitted by the player, together with the associated name - * to the `eventOutput` callback. - */ - private val playerEventRelay = EventRelay( - EVENT_CLASS_TO_REACT_NATIVE_NAME_MAPPING, - ::emitEventFromPlayer, - ) - /** * Relays the provided set of events, emitted by the player view, together with the associated name * to the `eventOutput` callback. 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 1cb0ffe2..842c945f 100644 --- a/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerViewManager.kt +++ b/android/src/main/java/com/bitmovin/player/reactnative/RNPlayerViewManager.kt @@ -248,6 +248,7 @@ class RNPlayerViewManager(private val context: ReactApplicationContext) : Simple val playbackConfig = playerConfig?.getMap("playbackConfig") val isPictureInPictureEnabled = view.config?.pictureInPictureConfig?.isEnabled == true || playbackConfig?.getBooleanOrNull("isPictureInPictureEnabled") == true + view.enableBackgroundPlayback = playbackConfig?.getBoolean("isBackgroundPlaybackEnabled") ?: false val rnStyleConfigWrapper = playerConfig?.toRNStyleConfigWrapperFromPlayerConfig() val configuredPlayerViewConfig = view.config?.playerViewConfig ?: PlayerViewConfig() 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 cd6bec77..78438e5c 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 @@ -898,6 +898,14 @@ fun String.toMediaType(): MediaType? = when (this) { else -> null } +data class MediaControlConfig( + var isEnabled: Boolean = true, +) + +fun ReadableMap.toMediaControlConfig(): MediaControlConfig = MediaControlConfig().apply { + withBoolean("isEnabled") { isEnabled = it } +} + /** * Converts a [CastPayload] object into its JS representation. */ diff --git a/android/src/main/java/com/bitmovin/player/reactnative/services/MediaSessionPlaybackService.kt b/android/src/main/java/com/bitmovin/player/reactnative/services/MediaSessionPlaybackService.kt new file mode 100644 index 00000000..ff7afa28 --- /dev/null +++ b/android/src/main/java/com/bitmovin/player/reactnative/services/MediaSessionPlaybackService.kt @@ -0,0 +1,59 @@ +package com.bitmovin.player.reactnative.services + +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import com.bitmovin.player.api.Player +import com.bitmovin.player.api.media.session.MediaSession +import com.bitmovin.player.api.media.session.MediaSessionService + +class MediaSessionPlaybackService : MediaSessionService() { + inner class ServiceBinder : Binder() { + var player: Player? + get() = this@MediaSessionPlaybackService.player + set(value) { + if (player == value) { + return + } + + disconnectSession() + this@MediaSessionPlaybackService.player = value + value?.let { + createSession(it) + connectSession() + } + } + } + + private var player: Player? = null + private val binder = ServiceBinder() + private var mediaSession: MediaSession? = null + + override fun onGetSession(): MediaSession? = null + + override fun onDestroy() { + disconnectSession() + player = null + + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder { + super.onBind(intent) + return binder + } + + private fun createSession(player: Player) { + mediaSession = MediaSession( + this, + mainLooper, + player, + ) + } + + private fun connectSession() = mediaSession?.let { addSession(it) } + private fun disconnectSession() = mediaSession?.let { + removeSession(it) + it.release() + } +} diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 7559418f..edd84fd4 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + + + + + + + diff --git a/example/src/App.tsx b/example/src/App.tsx index d03bb24b..2e10c807 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -21,6 +21,7 @@ import LandscapeFullscreenHandling from './screens/LandscapeFullscreenHandling'; import SystemUI from './screens/SystemUi'; import OfflinePlayback from './screens/OfflinePlayback'; import Casting from './screens/Casting'; +import BackgroundPlayback from './screens/BackgroundPlayback'; export type RootStackParamsList = { ExamplesList: { @@ -60,6 +61,7 @@ export type RootStackParamsList = { }; Casting: undefined; SystemUI: undefined; + BackgroundPlayback: undefined; }; const RootStack = createNativeStackNavigator(); @@ -112,6 +114,10 @@ export default function App() { title: 'Programmatic Track Selection', routeName: 'ProgrammaticTrackSelection' as keyof RootStackParamsList, }, + { + title: 'Background Playback', + routeName: 'BackgroundPlayback' as keyof RootStackParamsList, + }, ], }; @@ -279,6 +285,11 @@ export default function App() { options={{ title: 'Casting' }} /> )} + ); diff --git a/example/src/screens/BackgroundPlayback.tsx b/example/src/screens/BackgroundPlayback.tsx new file mode 100644 index 00000000..ba5a30ba --- /dev/null +++ b/example/src/screens/BackgroundPlayback.tsx @@ -0,0 +1,90 @@ +import React, { useCallback } from 'react'; +import { View, Platform, StyleSheet } from 'react-native'; +import { useFocusEffect } from '@react-navigation/native'; +import { + Event, + usePlayer, + PlayerView, + SourceType, +} from 'bitmovin-player-react-native'; +import { useTVGestures } from '../hooks'; + +function prettyPrint(header: string, obj: any) { + console.log(header, JSON.stringify(obj, null, 2)); +} + +export default function BackgroundPlayback() { + useTVGestures(); + + const player = usePlayer({ + playbackConfig: { + isBackgroundPlaybackEnabled: true, + }, + mediaControlConfig: { + isEnabled: true, + }, + remoteControlConfig: { + isCastEnabled: false, + }, + }); + + useFocusEffect( + useCallback(() => { + player.load({ + 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 }, + }); + 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/ios/RCTConvert+BitmovinPlayer.swift b/ios/RCTConvert+BitmovinPlayer.swift index ef80c629..4cef95dd 100644 --- a/ios/RCTConvert+BitmovinPlayer.swift +++ b/ios/RCTConvert+BitmovinPlayer.swift @@ -42,6 +42,9 @@ extension RCTConvert { if let networkConfig = RCTConvert.networkConfig(json["networkConfig"]) { playerConfig.networkConfig = networkConfig } + if let nowPlayingConfig = RCTConvert.mediaControlConfig(json["mediaControlConfig"]) { + playerConfig.nowPlayingConfig = nowPlayingConfig + } #if os(iOS) if let remoteControlConfig = RCTConvert.remoteControlConfig(json["remoteControlConfig"]) { playerConfig.remoteControlConfig = remoteControlConfig @@ -1332,6 +1335,18 @@ extension RCTConvert { "body": toJson(data: httpResponse.body) ] } + + static func mediaControlConfig(_ json: Any?) -> NowPlayingConfig? { + let nowPlayingConfig = NowPlayingConfig() + guard let json = json as? [String: Any?] else { + nowPlayingConfig.isNowPlayingInfoEnabled = true + return nowPlayingConfig + } + if let isEnabled = json["isEnabled"] as? Bool { + nowPlayingConfig.isNowPlayingInfoEnabled = isEnabled + } + return nowPlayingConfig + } } /** * React native specific PlayerViewConfig. diff --git a/src/index.ts b/src/index.ts index b54b2472..20d34fc7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,3 +24,4 @@ export * from './playerConfig'; export * from './liveConfig'; export * from './bufferApi'; export * from './network'; +export * from './mediaControlConfig'; diff --git a/src/mediaControlConfig.ts b/src/mediaControlConfig.ts new file mode 100644 index 00000000..145a0c38 --- /dev/null +++ b/src/mediaControlConfig.ts @@ -0,0 +1,59 @@ +/** + * Configures the media control information for the application. This information will be displayed + * wherever current media information typically appears, such as the lock screen, in notifications, and + * and inside the control center. + */ +export interface MediaControlConfig { + /** + * Enable the default behavior of displaying media information + * on the lock screen, in notifications, and within the control center. + * + * Default is `true`. + * + * For a detailed list of the supported features in the **default behavior**, + * check the **Default Supported Features** section. + * + * @note Enabling this flag will automatically treat {@link TweaksConfig.updatesNowPlayingInfoCenter} as `false`. + * + * ## Limitations + * --- + * - Android: If an app creates multiple player instances, the player shown in media controls is the latest one created having media controls enabled. + * - At the moment, the current media information is disabled during casting. + * + * ## Known Issues + * --- + * **iOS**: + * - There is unexpected behavior when using the IMA SDK. The Google IMA SDK adds its own commands + * for play/pause as soon as the ad starts loading (not when it starts playing). Within this window + * (approximately around 10 seconds), it is possible that both the ad and the main content are playing + * at the same time when a user interacts with the media control feature. + * + * ## Default Supported Features + * --- + * Here is the list of features supported by the default behavior. + * + * ### Populated Metadata + * - media type (to visualize the correct kind of data — _e.g. a waveform for audio files_) + * - title + * - artwork + * - elapsed time + * - duration + * + * **Android-only** + * - source description + * + * **iOS-only** + * - live or VOD status + * - playback rate + * - default playback rate + * + * ### Registered Commands + * - toggle play/pause + * - change playback position + * + * **iOS-only** + * - skip forward + * - skip backward + */ + isEnabled?: boolean; +} diff --git a/src/playbackConfig.ts b/src/playbackConfig.ts index 70a5748e..acf019ee 100644 --- a/src/playbackConfig.ts +++ b/src/playbackConfig.ts @@ -47,19 +47,22 @@ export interface PlaybackConfig { * When set to `true`, also make sure to properly configure your app to allow * background playback. * - * On tvOS, background playback is only supported for audio-only content. - * * Default is `false`. * + * @note + * On Android, {@link MediaControlConfig.isEnabled} has to be `true` for + * background playback to work. + * @note + * On tvOS, background playback is only supported for audio-only content. + * * @example * ``` * const player = new Player({ - * { + * playbackConfig: { * isBackgroundPlaybackEnabled: true, - * } - * }) + * }, + * }); * ``` - * @platform iOS, tvOS */ isBackgroundPlaybackEnabled?: boolean; /** diff --git a/src/playerConfig.ts b/src/playerConfig.ts index cffe2308..e6a68690 100644 --- a/src/playerConfig.ts +++ b/src/playerConfig.ts @@ -9,6 +9,7 @@ import { NativeInstanceConfig } from './nativeInstance'; import { PlaybackConfig } from './playbackConfig'; import { LiveConfig } from './liveConfig'; import { NetworkConfig } from './network/networkConfig'; +import { MediaControlConfig } from './mediaControlConfig'; /** * Object used to configure a new `Player` instance. @@ -73,4 +74,10 @@ export interface PlayerConfig extends NativeInstanceConfig { * Configures network request manipulation functionality. A default {@link NetworkConfig} is set initially. */ networkConfig?: NetworkConfig; + /** + * Configures the media control information for the application. This information will be displayed + * wherever current media information typically appears, such as the lock screen, in notifications, + * and inside the control center. + */ + mediaControlConfig?: MediaControlConfig; } diff --git a/src/tweaksConfig.ts b/src/tweaksConfig.ts index 45ac1cfb..2269baf7 100644 --- a/src/tweaksConfig.ts +++ b/src/tweaksConfig.ts @@ -166,6 +166,7 @@ export interface TweaksConfig { * * Default is `true`. * + * @deprecated To enable the Now Playing information use {@link MediaControlConfig.isEnabled} * @platform iOS */ updatesNowPlayingInfoCenter?: boolean;