diff --git a/CHANGELOG.md b/CHANGELOG.md index ae271c3e..e9023c68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ ### Added -- `LockScreenControlConfig` to configure the lock screen information for the application. When `isEnabled` is `true`, the current media information will be shown on the lock-screen and within the control center +- `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 @@ -12,7 +13,7 @@ ### Deprecated -- `TweaksConfig.updatesNowPlayingInfoCenter` in favor of `LockScreenControlConfig.isEnabled` +- `TweaksConfig.updatesNowPlayingInfoCenter` in favor of `MediaControlConfig.isEnabled` ## [0.29.0] - 2024-09-09 diff --git a/android/build.gradle b/android/build.gradle index 67a97e89..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.84.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 bb83e8ca..1a0f6a89 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 @@ -883,6 +883,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 54799276..a8b3ea33 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -20,6 +20,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: { @@ -58,6 +59,7 @@ export type RootStackParamsList = { }; Casting: undefined; SystemUI: undefined; + BackgroundPlayback: undefined; }; const RootStack = createNativeStackNavigator(); @@ -109,6 +111,10 @@ export default function App() { title: 'Programmatic Track Selection', routeName: 'ProgrammaticTrackSelection' as keyof RootStackParamsList, }, + { + title: 'Background Playback', + routeName: 'BackgroundPlayback' as keyof RootStackParamsList, + }, ], }; @@ -262,6 +268,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 c9f73fa2..4034411f 100644 --- a/ios/RCTConvert+BitmovinPlayer.swift +++ b/ios/RCTConvert+BitmovinPlayer.swift @@ -42,7 +42,7 @@ extension RCTConvert { if let networkConfig = RCTConvert.networkConfig(json["networkConfig"]) { playerConfig.networkConfig = networkConfig } - if let nowPlayingConfig = RCTConvert.lockScreenControlConfig(json["lockScreenControlConfig"]) { + if let nowPlayingConfig = RCTConvert.mediaControlConfig(json["mediaControlConfig"]) { playerConfig.nowPlayingConfig = nowPlayingConfig } #if os(iOS) @@ -1332,11 +1332,12 @@ extension RCTConvert { ] } - static func lockScreenControlConfig(_ json: Any?) -> NowPlayingConfig? { + static func mediaControlConfig(_ json: Any?) -> NowPlayingConfig? { + let nowPlayingConfig = NowPlayingConfig() guard let json = json as? [String: Any?] else { - return nil + nowPlayingConfig.isNowPlayingInfoEnabled = true + return nowPlayingConfig } - let nowPlayingConfig = NowPlayingConfig() if let isEnabled = json["isEnabled"] as? Bool { nowPlayingConfig.isNowPlayingInfoEnabled = isEnabled } diff --git a/src/index.ts b/src/index.ts index f4219176..20d34fc7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,4 +24,4 @@ export * from './playerConfig'; export * from './liveConfig'; export * from './bufferApi'; export * from './network'; -export * from './lockScreenControlConfig'; +export * from './mediaControlConfig'; diff --git a/src/lockScreenControlConfig.ts b/src/mediaControlConfig.ts similarity index 65% rename from src/lockScreenControlConfig.ts rename to src/mediaControlConfig.ts index 89a9831b..145a0c38 100644 --- a/src/lockScreenControlConfig.ts +++ b/src/mediaControlConfig.ts @@ -1,13 +1,14 @@ /** - * Configures the lock screen information for the application. This information will be displayed - * wherever current media information typically appears, such as the lock screen + * 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 LockScreenControlConfig { +export interface MediaControlConfig { /** * Enable the default behavior of displaying media information - * on the lock screen and within the control center. - * Default is `false`. + * 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. @@ -16,6 +17,7 @@ export interface LockScreenControlConfig { * * ## 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 @@ -24,25 +26,32 @@ export interface LockScreenControlConfig { * - 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 lock-screen control feature. + * 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 - * - asset URL (to visualize the correct kind of data — _e.g. a waveform for audio files_) + * - 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 - * - elapsed time - * - duration * * ### Registered Commands * - toggle play/pause * - change playback position + * + * **iOS-only** * - skip forward * - skip backward */ 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 11e0e5b0..e6a68690 100644 --- a/src/playerConfig.ts +++ b/src/playerConfig.ts @@ -9,7 +9,7 @@ import { NativeInstanceConfig } from './nativeInstance'; import { PlaybackConfig } from './playbackConfig'; import { LiveConfig } from './liveConfig'; import { NetworkConfig } from './network/networkConfig'; -import { LockScreenControlConfig } from './lockScreenControlConfig'; +import { MediaControlConfig } from './mediaControlConfig'; /** * Object used to configure a new `Player` instance. @@ -75,9 +75,9 @@ export interface PlayerConfig extends NativeInstanceConfig { */ networkConfig?: NetworkConfig; /** - * Configures the lock screen information for the application. This information will be displayed - * wherever current media information typically appears, such as the lock screen + * 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. */ - lockScreenControlConfig?: LockScreenControlConfig; + mediaControlConfig?: MediaControlConfig; } diff --git a/src/tweaksConfig.ts b/src/tweaksConfig.ts index d55b7020..2269baf7 100644 --- a/src/tweaksConfig.ts +++ b/src/tweaksConfig.ts @@ -166,7 +166,7 @@ export interface TweaksConfig { * * Default is `true`. * - * @deprecated To enable the Now Playing information use {@link LockScreenControlConfig.isEnabled} + * @deprecated To enable the Now Playing information use {@link MediaControlConfig.isEnabled} * @platform iOS */ updatesNowPlayingInfoCenter?: boolean;