mirror of
https://github.com/zoriya/react-native-video.git
synced 2026-08-16 02:56:22 +00:00
feat(android): implement Picture-in-Picture (PiP) support for multiple videos playback (#4589)
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
+61
-3
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user