From 1e0536ce4f6a82e224c087656b51df9192a7ef38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Piekarz?= <125810604+Pieczasz@users.noreply.github.com> Date: Wed, 2 Jul 2025 10:39:15 +0200 Subject: [PATCH] feat(android): implement Picture-in-Picture (PiP) support for multiple videos playback (#4589) --- .../main/java/com/video/core/VideoManager.kt | 144 +++++++++++++++++- .../PictureInPictureHelperFragment.kt | 64 +++++++- .../video/core/utils/PictureInPictureUtils.kt | 33 +++- .../hybrids/videoplayer/HybridVideoPlayer.kt | 4 +- .../src/main/java/com/video/view/VideoView.kt | 96 ++++++++++-- 5 files changed, 325 insertions(+), 16 deletions(-) diff --git a/packages/react-native-video/android/src/main/java/com/video/core/VideoManager.kt b/packages/react-native-video/android/src/main/java/com/video/core/VideoManager.kt index 07f7b141..34b9ed49 100644 --- a/packages/react-native-video/android/src/main/java/com/video/core/VideoManager.kt +++ b/packages/react-native-video/android/src/main/java/com/video/core/VideoManager.kt @@ -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>() // player -> list of nitroIds of views that are using this player private val players = mutableMapOf>() + + // Keep track of players that were paused due to PiP so that they can be resumed later + private val playersPausedForPip = mutableSetOf() + + private var currentPipVideoView: WeakReference? = 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() } + } } diff --git a/packages/react-native-video/android/src/main/java/com/video/core/fragments/PictureInPictureHelperFragment.kt b/packages/react-native-video/android/src/main/java/com/video/core/fragments/PictureInPictureHelperFragment.kt index 7f534790..b8d50dc4 100644 --- a/packages/react-native-video/android/src/main/java/com/video/core/fragments/PictureInPictureHelperFragment.kt +++ b/packages/react-native-video/android/src/main/java/com/video/core/fragments/PictureInPictureHelperFragment.kt @@ -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") + } } } } diff --git a/packages/react-native-video/android/src/main/java/com/video/core/utils/PictureInPictureUtils.kt b/packages/react-native-video/android/src/main/java/com/video/core/utils/PictureInPictureUtils.kt index bf8f2d79..7a46526d 100644 --- a/packages/react-native-video/android/src/main/java/com/video/core/utils/PictureInPictureUtils.kt +++ b/packages/react-native-video/android/src/main/java/com/video/core/utils/PictureInPictureUtils.kt @@ -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 + } + } } diff --git a/packages/react-native-video/android/src/main/java/com/video/hybrids/videoplayer/HybridVideoPlayer.kt b/packages/react-native-video/android/src/main/java/com/video/hybrids/videoplayer/HybridVideoPlayer.kt index ca5fca36..bc2d3eb2 100644 --- a/packages/react-native-video/android/src/main/java/com/video/hybrids/videoplayer/HybridVideoPlayer.kt +++ b/packages/react-native-video/android/src/main/java/com/video/hybrids/videoplayer/HybridVideoPlayer.kt @@ -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() } } } diff --git a/packages/react-native-video/android/src/main/java/com/video/view/VideoView.kt b/packages/react-native-video/android/src/main/java/com/video/view/VideoView.kt index 37596b73..67998345 100644 --- a/packages/react-native-video/android/src/main/java/com/video/view/VideoView.kt +++ b/packages/react-native-video/android/src/main/java/com/video/view/VideoView.kt @@ -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()