feat(android): implement Picture-in-Picture (PiP) support for multiple videos playback (#4589)

This commit is contained in:
Bartłomiej Piekarz
2025-07-02 10:39:15 +02:00
committed by GitHub
parent e94d16cbcf
commit 1e0536ce4f
5 changed files with 325 additions and 16 deletions
@@ -1,28 +1,113 @@
package com.video.core
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.facebook.react.bridge.LifecycleEventListener
import com.margelo.nitro.NitroModules
import com.margelo.nitro.video.HybridVideoPlayer
import com.margelo.nitro.video.MixAudioMode
import com.video.view.VideoView
import java.lang.ref.WeakReference
@OptIn(UnstableApi::class)
object VideoManager : LifecycleEventListener {
private const val TAG = "VideoManager"
// nitroId -> weak VideoView
private val views = mutableMapOf<Int, WeakReference<VideoView>>()
// player -> list of nitroIds of views that are using this player
private val players = mutableMapOf<HybridVideoPlayer, MutableList<Int>>()
// Keep track of players that were paused due to PiP so that they can be resumed later
private val playersPausedForPip = mutableSetOf<HybridVideoPlayer>()
private var currentPipVideoView: WeakReference<VideoView>? = null
var audioFocusManager = AudioFocusManager()
private var lastPlayedNitroId: Int? = null
init {
NitroModules.applicationContext?.apply {
addLifecycleEventListener(this@VideoManager)
}
}
fun requestPictureInPicture(videoView: VideoView): Boolean {
Log.d(TAG, "PiP requested for video nitroId: ${videoView.nitroId}")
if (videoView.isInPictureInPicture) {
Log.d(TAG, "Video nitroId: ${videoView.nitroId} is already in PiP")
return true
}
// Exit PiP from current video if there is one
currentPipVideoView?.get()?.let { currentPipVideo ->
if (currentPipVideo != videoView && currentPipVideo.isInPictureInPicture) {
Log.d(TAG, "Forcing exit PiP for video nitroId: ${currentPipVideo.nitroId} to make room for nitroId: ${videoView.nitroId}")
currentPipVideo.forceExitPictureInPicture()
}
}
// Ensure the player that belongs to this view is attached back to this view
videoView.hybridPlayer?.movePlayerToVideoView(videoView)
// Pause every other player that might be playing in the background so that only the PiP video plays
pauseOtherPlayers(videoView)
// Set this video as the designated PiP video BEFORE entering PiP mode
// This ensures the PictureInPictureHelperFragment callbacks know which video should respond
currentPipVideoView = WeakReference(videoView)
Log.d(TAG, "Designated video nitroId: ${videoView.nitroId} as the PiP video")
val success = videoView.internalEnterPictureInPicture()
Log.d(TAG, "PiP enter result for video nitroId: ${videoView.nitroId} = $success")
if (!success) {
// If we failed to enter PiP, resume any players we just paused
resumePlayersPausedForPip()
currentPipVideoView = null
Log.w(TAG, "Failed to enter PiP, clearing designated PiP video")
}
return success
}
fun notifyPictureInPictureExited(videoView: VideoView) {
Log.d(TAG, "PiP exit notification for video nitroId: ${videoView.nitroId}")
currentPipVideoView?.get()?.let { currentPipVideo ->
if (currentPipVideo == videoView) {
Log.d(TAG, "Clearing PiP reference for video nitroId: ${videoView.nitroId}")
currentPipVideoView = null
// Resume any players that were paused when PiP was entered
resumePlayersPausedForPip()
}
}
}
fun getCurrentPictureInPictureVideo(): VideoView? {
return currentPipVideoView?.get()
}
fun setCurrentPictureInPictureVideo(videoView: VideoView) {
Log.d(TAG, "Setting current PiP video to nitroId: ${videoView.nitroId}")
currentPipVideoView = WeakReference(videoView)
}
fun isAnyVideoInPictureInPicture(): Boolean {
return currentPipVideoView?.get()?.isInPictureInPicture == true
}
fun forceExitAllPictureInPicture() {
currentPipVideoView?.get()?.let { currentPipVideo ->
if (currentPipVideo.isInPictureInPicture) {
currentPipVideo.forceExitPictureInPicture()
}
}
currentPipVideoView = null
}
fun maybePassPlayerToView(player: HybridVideoPlayer) {
val views = players[player]?.mapNotNull { getVideoViewWeakReferenceByNitroId(it)?.get() } ?: return
val latestView = views.lastOrNull() ?: return
@@ -39,6 +124,13 @@ object VideoManager : LifecycleEventListener {
removeViewFromPlayer(view, it)
}
// Clean up PiP reference if this view was in PiP
currentPipVideoView?.get()?.let { currentPipVideo ->
if (currentPipVideo == view) {
currentPipVideoView = null
}
}
views.remove(view.nitroId)
}
@@ -133,5 +225,55 @@ object VideoManager : LifecycleEventListener {
onAppEnterBackground()
}
override fun onHostDestroy() {}
override fun onHostDestroy() {
forceExitAllPictureInPicture()
}
fun pauseOtherPlayers(pipVideoView: VideoView) {
val pipPlayer = pipVideoView.hybridPlayer
playersPausedForPip.clear()
players.keys.forEach { player ->
// Skip the player that is used for the PiP view
if (player == pipPlayer) return@forEach
// Pause only if it is currently playing
if (player.isPlaying && player.mixAudioMode != MixAudioMode.MIXWITHOTHERS) {
player.pause()
playersPausedForPip.add(player)
Log.v(TAG, "Paused player for PiP (nitroIds: ${players[player]})")
}
}
}
private fun resumePlayersPausedForPip() {
playersPausedForPip.forEach { player ->
// Ensure the player is attached to the latest visible VideoView before resuming
maybePassPlayerToView(player)
if (!player.isPlaying) {
player.play()
Log.v(TAG, "Resumed player after PiP exit (nitroIds: ${players[player]})")
}
}
playersPausedForPip.clear()
}
fun getAnyPlayingVideoView(): VideoView? {
return views.values.firstOrNull { ref ->
ref.get()?.hybridPlayer?.isPlaying == true
}?.get()
}
fun setLastPlayedPlayer(player: HybridVideoPlayer) {
// Resolve to the latest view using this player (usually the last one in the list)
val nitroIds = players[player] ?: return
if (nitroIds.isNotEmpty()) {
lastPlayedNitroId = nitroIds.last()
}
}
fun getLastPlayedVideoView(): VideoView? {
return lastPlayedNitroId?.let { views[it]?.get() }
}
}
@@ -2,9 +2,11 @@ package com.video.core.fragments
import android.content.res.Configuration
import android.os.Bundle
import android.util.Log
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.video.core.VideoManager
import com.video.view.VideoView
import java.util.UUID
@@ -14,11 +16,67 @@ class PictureInPictureHelperFragment(private val videoView: VideoView) : Fragmen
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
if (!isInPictureInPictureMode) {
videoView.exitPictureInPicture()
} else {
Log.d("ReactNativeVideo", "PiP mode change callback - fragment for nitroId: ${videoView.nitroId}, entering PiP: $isInPictureInPictureMode")
if (isInPictureInPictureMode) {
// Entering PiP mode. Check if we have a designated video, if not, determine which one should handle it.
var currentPipVideo = VideoManager.getCurrentPictureInPictureVideo()
Log.d("ReactNativeVideo", "Current PiP video nitroId: ${currentPipVideo?.nitroId}, this video nitroId: ${videoView.nitroId}")
if (currentPipVideo == null) {
// No video was designated (auto-enter PiP scenario)
// Use synchronized block to prevent race condition between multiple fragments
synchronized(VideoManager) {
currentPipVideo = VideoManager.getCurrentPictureInPictureVideo()
if (currentPipVideo == null) {
Log.d("ReactNativeVideo", "No designated PiP video found, determining best candidate")
if (videoView.hybridPlayer?.isPlaying == true) {
val lastPlayed = VideoManager.getLastPlayedVideoView()
val shouldDesignate =
lastPlayed == null || lastPlayed.nitroId == videoView.nitroId
if (shouldDesignate) {
VideoManager.setCurrentPictureInPictureVideo(videoView)
videoView.hybridPlayer?.movePlayerToVideoView(videoView)
VideoManager.pauseOtherPlayers(videoView)
currentPipVideo = videoView
}
} else {
// If this video is not playing, check if no other playing view exists and use last-played fallback
if (!VideoManager.isAnyVideoInPictureInPicture()) {
val lastPlayed = VideoManager.getLastPlayedVideoView()
val targetView = lastPlayed ?: videoView
VideoManager.setCurrentPictureInPictureVideo(targetView)
targetView.hybridPlayer?.movePlayerToVideoView(targetView)
VideoManager.pauseOtherPlayers(targetView)
currentPipVideo = targetView
}
}
} else {
Log.d("ReactNativeVideo", "Another fragment already designated a PiP video: ${currentPipVideo?.nitroId}")
}
}
}
if (currentPipVideo == videoView) {
Log.d("ReactNativeVideo", "Entering PiP mode for correct video nitroId: ${videoView.nitroId}")
videoView.hideRootContentViews()
videoView.isInPictureInPicture = true
} else {
Log.d("ReactNativeVideo", "Ignoring PiP enter for video nitroId: ${videoView.nitroId} - not the designated PiP video")
}
} else {
// Exiting PiP mode. Any view that currently thinks it is in PiP should handle the exit.
if (videoView.isInPictureInPicture) {
Log.d("ReactNativeVideo", "Exiting PiP mode for video nitroId: ${videoView.nitroId}")
videoView.exitPictureInPicture()
} else {
Log.d("ReactNativeVideo", "Ignoring PiP exit for video nitroId: ${videoView.nitroId} - not currently in PiP")
}
}
}
}
@@ -4,6 +4,7 @@ import android.app.PictureInPictureParams
import android.content.pm.PackageManager
import android.graphics.Rect
import android.os.Build
import android.util.Log
import android.util.Rational
import android.view.View
import androidx.annotation.OptIn
@@ -14,6 +15,7 @@ import com.video.view.VideoView
@OptIn(UnstableApi::class)
object PictureInPictureUtils {
private const val TAG = "PictureInPictureUtils"
fun canEnterPictureInPicture(): Boolean {
val applicationContent = NitroModules.applicationContext
@@ -89,10 +91,39 @@ object PictureInPictureUtils {
try {
val currentActivity = NitroModules.applicationContext?.currentActivity
currentActivity?.setPictureInPictureParams(params)
} catch (_: Exception) {
Log.d(TAG, "Successfully set PiP params")
} catch (e: Exception) {
Log.w(TAG, "Failed to set PiP params - PiP may not be enabled in manifest", e)
// Ignore: We cannot check if user has added support for PIP in manifest
// so we need to catch error if he did not add it.
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun safeEnterPictureInPictureMode(params: PictureInPictureParams): Boolean {
return try {
val currentActivity = NitroModules.applicationContext?.currentActivity
val result = currentActivity?.enterPictureInPictureMode(params) ?: false
Log.d(TAG, "PiP enter result: $result")
result
} catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP mode", e)
false
}
}
fun isCurrentlyInPictureInPictureMode(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val currentActivity = NitroModules.applicationContext?.currentActivity
currentActivity?.isInPictureInPictureMode == true
} catch (e: Exception) {
Log.w(TAG, "Failed to check PiP mode status", e)
false
}
} else {
false
}
}
}
@@ -461,6 +461,7 @@ class HybridVideoPlayer() : HybridVideoPlayerSpec() {
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
eventEmitter.onPlaybackStateChange(
onPlaybackStateChangeData(
isPlaying = isPlaying,
@@ -468,10 +469,11 @@ class HybridVideoPlayer() : HybridVideoPlayerSpec() {
)
)
if (isPlaying) {
VideoManager.setLastPlayedPlayer(this@HybridVideoPlayer)
startProgressUpdates()
} else {
if (playerPointer.playbackState == Player.STATE_ENDED || playerPointer.playbackState == Player.STATE_IDLE) {
stopProgressUpdates()
stopProgressUpdates()
}
}
}
@@ -30,6 +30,7 @@ import com.video.core.fragments.FullscreenVideoFragment
import com.video.core.fragments.PictureInPictureHelperFragment
import com.video.core.utils.PictureInPictureUtils.canEnterPictureInPicture
import com.video.core.utils.PictureInPictureUtils.createPictureInPictureParams
import com.video.core.utils.PictureInPictureUtils.safeEnterPictureInPictureMode
import com.video.core.utils.Threading.runOnMainThread
import com.video.core.extensions.toAspectRatioFrameLayout
import com.video.core.utils.PictureInPictureUtils
@@ -276,6 +277,14 @@ class VideoView @JvmOverloads constructor(
}
fun hideRootContentViews() {
// Safety check: Only proceed if this is the designated PiP video
if (VideoManager.getCurrentPictureInPictureVideo() != this) {
Log.w("ReactNativeVideo", "hideRootContentViews called on non-PiP video nitroId: $nitroId - ignoring")
return
}
Log.d("ReactNativeVideo", "Hiding root content views for PiP video nitroId: $nitroId")
// Remove playerView from parent
// In PiP mode, we don't want to show the controller
// Controls are handled by System if we have MediaSession
@@ -298,9 +307,13 @@ class VideoView @JvmOverloads constructor(
rootContent.addView(playerView,
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
)
Log.d("ReactNativeVideo", "Successfully moved player view to root content for PiP nitroId: $nitroId")
}
fun restoreRootContentViews() {
Log.d("ReactNativeVideo", "Restoring root content views for video nitroId: $nitroId")
// Reset PlayerView settings
playerView.useController = useController
@@ -317,6 +330,8 @@ class VideoView @JvmOverloads constructor(
// Add PlayerView back to VideoView
addView(playerView)
Log.d("ReactNativeVideo", "Successfully restored root content views for video nitroId: $nitroId")
}
fun enterPictureInPicture() {
@@ -328,30 +343,91 @@ class VideoView @JvmOverloads constructor(
throw VideoViewError.PictureInPictureNotSupported
}
val currentActivity = applicationContent.currentActivity ?: return
// Use centralized PiP manager to handle multiple videos
VideoManager.requestPictureInPicture(this)
}
events.willEnterPictureInPicture?.let { it() }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val params = createPictureInPictureParams(this)
currentActivity.enterPictureInPictureMode(params)
} else {
@Suppress("Deprecation")
currentActivity.enterPictureInPictureMode()
internal fun internalEnterPictureInPicture(): Boolean {
if (isInPictureInPicture || isInFullscreen || !pictureInPictureEnabled) {
return false
}
isInPictureInPicture = true
if (!canEnterPictureInPicture()) {
return false
}
val currentActivity = applicationContent.currentActivity ?: return false
return try {
events.willEnterPictureInPicture?.let { it() }
val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val params = createPictureInPictureParams(this)
safeEnterPictureInPictureMode(params)
} else {
try {
@Suppress("Deprecation")
currentActivity.enterPictureInPictureMode()
true
} catch (e: Exception) {
Log.e("ReactNativeVideo", "Failed to enter PiP mode (legacy)", e)
false
}
}
if (success) {
Log.d("ReactNativeVideo", "Successfully requested PiP mode for nitroId: $nitroId")
// Note: isInPictureInPicture will be set to true by the system callback in PictureInPictureHelperFragment
} else {
Log.w("ReactNativeVideo", "Failed to enter PiP mode for nitroId: $nitroId")
}
success
} catch (e: Exception) {
Log.e("ReactNativeVideo", "Exception in internalEnterPictureInPicture for nitroId: $nitroId", e)
false
}
}
fun exitPictureInPicture() {
if (!isInPictureInPicture || isInFullscreen) {
return
}
VideoManager.notifyPictureInPictureExited(this)
events.willExitPictureInPicture?.let { it() }
restoreRootContentViews()
isInPictureInPicture = false
}
internal fun forceExitPictureInPicture() {
if (!isInPictureInPicture || isInFullscreen) {
Log.d("ReactNativeVideo", "Force exit PiP skipped for nitroId: $nitroId (not in PiP or in fullscreen)")
return
}
Log.d("ReactNativeVideo", "Force exiting PiP for nitroId: $nitroId")
try {
val currentActivity = applicationContent.currentActivity
if (currentActivity?.isInPictureInPictureMode == true) {
Log.d("ReactNativeVideo", "Activity is in PiP mode, preparing for transition")
}
events.willExitPictureInPicture?.let { it() }
restoreRootContentViews()
isInPictureInPicture = false
VideoManager.notifyPictureInPictureExited(this)
Log.d("ReactNativeVideo", "Successfully force exited PiP for nitroId: $nitroId")
} catch (e: Exception) {
Log.e("ReactNativeVideo", "Failed to force exit PiP mode for nitroId: $nitroId", e)
}
}
// -------- View Lifecycle Methods --------
override fun onDetachedFromWindow() {
removePipHelper()