Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Android: support Media Controls #539

Merged
Show file tree
Hide file tree
Changes from 56 commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
eb019c3
Implement lock-screen controls
123mpozzi Oct 14, 2024
1579ed5
Move the player instance creation to the service
123mpozzi Oct 16, 2024
dc1f4bf
Bind the player correctly to the service on player creation
123mpozzi Oct 16, 2024
f08ca29
Remove media session module from typescript
123mpozzi Oct 16, 2024
07a70c6
Fix media session player not getting replaced properly
123mpozzi Oct 16, 2024
257ca00
Introduce the lock-screen controls sample
123mpozzi Oct 17, 2024
ae88186
Revert changes to basic playback
123mpozzi Oct 17, 2024
140317a
Fix auto-pausing when minimizing the app
123mpozzi Oct 17, 2024
904d8c0
Fix every sample having background playback
123mpozzi Oct 18, 2024
34f63e1
Fix every sample having background playback after service is on
123mpozzi Oct 18, 2024
7fde45f
Cleanup code
123mpozzi Oct 18, 2024
5c04e05
Cleanup unnecessary code and comments
123mpozzi Oct 18, 2024
ec76873
Remove unnecessary decorator
123mpozzi Oct 18, 2024
9a39c49
Make the connection manager a normal class instead of a module
123mpozzi Oct 18, 2024
2d14fb7
Add newline at end of the file
123mpozzi Oct 18, 2024
40be6a7
Update docs with android details
123mpozzi Oct 18, 2024
e239fce
Simplify `LockScreenControlConfig` serialization
123mpozzi Oct 23, 2024
a3698fd
Update docs for bg-playback support on Android
123mpozzi Oct 23, 2024
44008f9
Add bg playback sample
123mpozzi Oct 23, 2024
64a572c
temp: tentative changes
123mpozzi Oct 23, 2024
96eafe9
Unify most of the logic for bg and mediasession
123mpozzi Oct 23, 2024
fca2dda
Rename bg manager more generic and run ktlint
123mpozzi Oct 23, 2024
08d0dd5
Remove unnecessary `onCreate`
123mpozzi Oct 24, 2024
c78df51
Format
123mpozzi Oct 24, 2024
d534946
Merge branch 'feature/android-enable-lock-screen-controls' into featu…
123mpozzi Oct 24, 2024
30e5466
pair-programming : just use one service, destroy media session
123mpozzi Oct 24, 2024
a0c9e86
Remove simple bg service
123mpozzi Oct 24, 2024
f2659cc
pair-programming session to unify services
123mpozzi Oct 24, 2024
8a80b44
Rename service
123mpozzi Oct 25, 2024
6181e74
Make media session independent of background playback
123mpozzi Oct 25, 2024
89f856f
Simplify since media session can be on without bg playback
123mpozzi Oct 25, 2024
62c7c42
Enable lock-screen to provide a notification
123mpozzi Oct 25, 2024
b7e672f
Use simpler names for variables
123mpozzi Oct 25, 2024
496edd9
Add note about Android needing a notification for bg playback
123mpozzi Oct 25, 2024
5c3bb13
Rename BackgroundPlaybackService-related classes and vars
123mpozzi Oct 25, 2024
1233369
Merge pull request #543 from bitmovin/feature/enhance-lock-screen-con…
123mpozzi Oct 25, 2024
503a4fc
Move `playerEventRelay` initialization outside the `init` block
123mpozzi Nov 4, 2024
5a4ff3c
Remove confusing comment
123mpozzi Nov 4, 2024
abaedf2
Allow proper setting to `null` as well, and remove useless code
123mpozzi Nov 4, 2024
7356122
Fix player recovering from service
123mpozzi Nov 5, 2024
95b632e
Refactor: move bg playback flag to view and cleanup
123mpozzi Nov 5, 2024
fe1e651
Rename lockScreenControlConfig to mediaControlConfig
123mpozzi Nov 5, 2024
72f68f1
Enable media controls by default
123mpozzi Nov 5, 2024
b97c8c1
Rename sample as well
123mpozzi Nov 6, 2024
2ee1f11
Fix wrong default value
123mpozzi Nov 6, 2024
d244a2c
Remove default value in case of nil
123mpozzi Nov 6, 2024
36e88e6
Fix state management
123mpozzi Nov 6, 2024
8d2fdb3
Export bg playback handling in a func for `onStop`
123mpozzi Nov 6, 2024
2f09d63
Fix default value
123mpozzi Nov 6, 2024
e22981e
Move statement inside block
123mpozzi Nov 6, 2024
a702767
Remove media control sample since it is enabled by default
123mpozzi Nov 6, 2024
cf7c34f
Move initialization of media session manager outside init block
123mpozzi Nov 6, 2024
f0b06d7
Optimize var
123mpozzi Nov 6, 2024
ad495fb
Document android limitation
123mpozzi Nov 6, 2024
5549678
Remove unnecessary code
123mpozzi Nov 6, 2024
adb0ea1
Remove correct line
123mpozzi Nov 6, 2024
6d62572
Update to stable version having Media Session API
123mpozzi Nov 6, 2024
b12d6b0
Do not use `let` for nullability check to save indentation space
123mpozzi Nov 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@

### 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Maybe a bit more specific?

Suggested change
- `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
- `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 and the Control Center on iOS and as a notification on Android

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to keep it as it is since the widget also pops up in the lock-screen in Android as well

- Android: `playerConfig.playbackConfig.isBackgroundPlaybackEnabled` to support background playback

### Changed

- Update Bitmovin's native Android SDK version to `3.84.0`

### Deprecated

- `TweaksConfig.updatesNowPlayingInfoCenter` in favor of `LockScreenControlConfig.isEnabled`
- `TweaksConfig.updatesNowPlayingInfoCenter` in favor of `MediaControlConfig.isEnabled`

## [0.29.0] - 2024-09-09

Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -25,6 +26,8 @@ class PlayerModule(context: ReactApplicationContext) : BitmovinBaseModule(contex
*/
private val players: Registry<Player> = mutableMapOf()

val mediaSessionPlaybackManager = MediaSessionPlaybackManager(context)

/**
* JS exported module name.
*/
Expand Down Expand Up @@ -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
hawk23 marked this conversation as resolved.
Show resolved Hide resolved

val networkConfig = networkNativeId?.let { networkModule.getConfig(it) }
if (networkConfig != null) {
Expand All @@ -90,6 +95,12 @@ class PlayerModule(context: ReactApplicationContext) : BitmovinBaseModule(contex
defaultMetadata = defaultMetadata ?: DefaultMetadata(),
)
}

if (enableMediaSession) {
promise.unit.resolveOnUiThread {
mediaSessionPlaybackManager.setupMediaSessionPlayback(nativeId)
}
}
}

/**
Expand Down Expand Up @@ -211,6 +222,7 @@ class PlayerModule(context: ReactApplicationContext) : BitmovinBaseModule(contex
*/
@ReactMethod
fun destroy(nativeId: NativeId, promise: Promise) {
mediaSessionPlaybackManager.destroy(nativeId)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be inside the resolveOnUiThreadWithPlayer block?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We spotted a bug here: the media session was not destroyed if put inside the resolve block.

The issue is that, at this point, the main thread is already killed from JS, so if we put this inside the resolve block, the code is not reached

promise.unit.resolveOnUiThreadWithPlayer(nativeId) {
destroy()
players.remove(nativeId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Player, Event> = EventRelay<Player, Event>(
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()
}

Expand All @@ -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
playerView?.player?.let {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'm not a big fan of using let for nullability checks as the code gets an extra indentation, consider replacing it with:
val player = playerView?.player ?: return

It's also generally nice to avoid it and name the parameter if the code is more than 1 line.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address in b12d6b0

if (!enableBackgroundPlayback) {
return
}
if (context.playerModule?.mediaSessionPlaybackManager?.player != it) {
return
}

playerInMediaSessionService = it
playerView?.player = null
}
}
}

init {
Expand All @@ -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<Player, Event>(
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This config is a cross-platform wrapper, it does not exist in either native SDK.
It acts as a common name to reach the feature

/**
* Converts a [CastPayload] object into its JS representation.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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() = [email protected]
set(value) {
if (player == value) {
return
}

disconnectSession()
[email protected] = value
value?.let {
createSession(it)
connectSession()
}
hawk23 marked this conversation as resolved.
Show resolved Hide resolved
}
}

private var player: Player? = null
private val binder = ServiceBinder()
private var mediaSession: MediaSession? = null

override fun onGetSession(): MediaSession? = null
zigavehovec marked this conversation as resolved.
Show resolved Hide resolved

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()
}
}
10 changes: 10 additions & 0 deletions example/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!--END-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

<application
android:name=".MainApplication"
Expand Down Expand Up @@ -52,5 +53,14 @@
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.bitmovin.player.casting.BitmovinCastOptionsProvider" />

<service
android:name="com.bitmovin.player.reactnative.services.MediaSessionPlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>

</application>
</manifest>
11 changes: 11 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -58,6 +59,7 @@ export type RootStackParamsList = {
};
Casting: undefined;
SystemUI: undefined;
BackgroundPlayback: undefined;
};

const RootStack = createNativeStackNavigator<RootStackParamsList>();
Expand Down Expand Up @@ -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,
},
],
};

Expand Down Expand Up @@ -262,6 +268,11 @@ export default function App() {
options={{ title: 'Casting' }}
/>
)}
<RootStack.Screen
name="BackgroundPlayback"
component={BackgroundPlayback}
options={{ title: 'Background Playback' }}
/>
</RootStack.Navigator>
</NavigationContainer>
);
Expand Down
Loading
Loading