refactor(android): use fragment instead of activity for fullscreen view

This commit is contained in:
Krzysztof Moch
2025-06-02 16:29:53 +02:00
parent 20fa017fc0
commit ddca01fa9f
9 changed files with 330 additions and 236 deletions
@@ -1,10 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application>
<activity android:name="com.video.core.activities.FullscreenVideoViewActivity"
android:supportsPictureInPicture="true"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden"
/>
</application>
</manifest>
@@ -1,10 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application>
<activity android:name="com.video.core.activities.FullscreenVideoViewActivity"
android:supportsPictureInPicture="true"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden"
/>
</application>
</manifest>
@@ -3,7 +3,6 @@ package com.video.core
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.margelo.nitro.video.HybridVideoPlayer
import com.video.core.activities.FullscreenVideoViewActivity
import com.video.view.VideoView
import java.lang.ref.WeakReference
@@ -13,16 +12,8 @@ object VideoManager {
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>>()
// fullscreen activity id (hash code) -> weak FullscreenVideoViewActivity
private val fullscreenActivities = mutableMapOf<Int, WeakReference<FullscreenVideoViewActivity>>()
fun maybePassPlayerToView(player: HybridVideoPlayer) {
// If we have fullscreen activity open, we don't want to move player from it
// Fullscreen activity will attach player to view after destroy
if (fullscreenActivities.isNotEmpty()) {
return
}
val views = players[player]?.mapNotNull { getVideoViewWeakReferenceByNitroId(it)?.get() } ?: return
val latestView = views.lastOrNull() ?: return
@@ -52,72 +43,54 @@ object VideoManager {
players[player]?.add(view.nitroId)
}
fun removeViewFromPlayer(view: VideoView, player: HybridVideoPlayer, moveToLatestView: Boolean = true) {
fun removeViewFromPlayer(view: VideoView, player: HybridVideoPlayer) {
players[player]?.remove(view.nitroId)
if(moveToLatestView) maybePassPlayerToView(player)
// If this was the last view using this player, clean up
if (players[player]?.isEmpty() == true) {
players.remove(player)
} else {
// If there are other views using this player, move to the latest one
maybePassPlayerToView(player)
}
}
fun registerPlayer(player: HybridVideoPlayer) {
players[player] = players.getOrDefault(player, mutableListOf())
if (!players.containsKey(player)) {
players[player] = mutableListOf()
}
}
fun unregisterPlayer(player: HybridVideoPlayer) {
// clear player from all views
val views = players[player]?.mapNotNull { getVideoViewWeakReferenceByNitroId(it)?.get() } ?: return
views.forEach { view ->
// We are destroying player, so we don't need to look for a new view
removeViewFromPlayer(view, player, moveToLatestView = false)
}
// Clear player from views
views.forEach {
it.hybridPlayer = null
}
players.remove(player)
}
fun registerFullscreenActivity(activity: FullscreenVideoViewActivity, id: Int) {
fullscreenActivities[id] = WeakReference(activity)
fun getPlayerByNitroId(nitroId: Int): HybridVideoPlayer? {
return players.keys.find { player ->
players[player]?.contains(nitroId) == true
}
}
fun unregisterFullscreenActivity(id: Int, player: HybridVideoPlayer?, moveToLatestView: Boolean = true) {
fullscreenActivities.remove(id)
if (player != null && moveToLatestView) {
maybePassPlayerToView(player)
fun updateVideoViewNitroId(oldNitroId: Int, newNitroId: Int, view: VideoView) {
// Remove old mapping
if (oldNitroId != -1) {
views.remove(oldNitroId)
// Update player mappings
players.keys.forEach { player ->
players[player]?.let { nitroIds ->
if (nitroIds.remove(oldNitroId)) {
nitroIds.add(newNitroId)
}
}
}
}
// Add new mapping
views[newNitroId] = WeakReference(view)
}
fun getVideoViewWeakReferenceByNitroId(nitroId: Int): WeakReference<VideoView>? {
return views[nitroId]
}
fun updateVideoViewNitroId(oldNitroId: Int, newNitroId: Int, view: VideoView) {
// Update view in views map
views.remove(oldNitroId)
views[newNitroId] = WeakReference(view)
// Update view in players map
players.forEach { (_, nitroIds) ->
// replace old id with new id (keep order)
val index = nitroIds.indexOf(oldNitroId)
if (index != -1) {
nitroIds[index] = newNitroId
}
}
// Update view in fullscreen activities map
fullscreenActivities.forEach { (_, activity) ->
if (activity.get()?.videoViewNitroId == oldNitroId) {
activity.get()?.videoViewNitroId = newNitroId
}
}
}
fun getPlayerByNitroId(nitroId: Int): HybridVideoPlayer? {
return players.entries.firstOrNull { it.value.contains(nitroId) }?.key
}
}
@@ -1,126 +0,0 @@
package com.video.core.activities
import android.annotation.SuppressLint
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.util.Rational
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.widget.ImageButton
import androidx.annotation.RequiresApi
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerView
import com.margelo.nitro.video.HybridVideoPlayer
import com.video.R
import com.video.core.VideoManager
import com.video.core.utils.PictureInPictureUtils.calculateAspectRatio
import com.video.core.utils.PictureInPictureUtils.calculateSourceRectHint
import com.video.view.VideoView
import java.lang.ref.WeakReference
@UnstableApi
class FullscreenVideoViewActivity : Activity() {
private lateinit var container: View
lateinit var playerView: PlayerView
var videoViewNitroId: Int = -1
private var videoView: WeakReference<VideoView>? = null
private lateinit var player: HybridVideoPlayer
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.fullscreen_video_view_activity)
container = findViewById(R.id.fullscreen_container)
playerView = findViewById(R.id.player_view)
try {
videoViewNitroId = intent.getIntExtra("nitroId", -1)
if (videoViewNitroId == -1) throw Exception("nitroId not found")
videoView = VideoManager.getVideoViewWeakReferenceByNitroId(videoViewNitroId)
player = VideoManager.getPlayerByNitroId(videoViewNitroId)
?: throw Exception("Player not found")
player.moveToFullscreenActivity(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val params = PictureInPictureParams.Builder()
.setAutoEnterEnabled(videoView?.get()?.autoEnterPictureInPicture == true)
.setSourceRectHint(calculateSourceRectHint(playerView))
.setAspectRatio(calculateAspectRatio(playerView))
.build()
setPictureInPictureParams(params)
}
} catch (error: Error) {
Log.e("ReactNativeVideo - FullscreenVideoViewActivity", error.message, error)
finish()
return
}
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
if (isInPictureInPictureMode) {
playerView.useController = false
} else {
playerView.useController = videoView?.get()?.useController == true
}
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
setupFullScreenButton()
playerView.setShowSubtitleButton(true)
hideSystemUI()
}
override fun onDestroy() {
super.onDestroy()
finish()
}
override fun finish() {
super.finish()
VideoManager.unregisterFullscreenActivity(hashCode(), player)
videoView?.get()?.exitFullscreen()
}
@SuppressLint("PrivateResource")
private fun setupFullScreenButton() {
playerView.setFullscreenButtonClickListener { _ ->
finish()
}
// We need to manually change icon, as we are using separate PlayerView in fullscreen activity
val button = playerView.findViewById<ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
button.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_exit)
}
@Suppress("DEPRECATION")
private fun hideSystemUI() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
container.fitsSystemWindows = false
container.windowInsetsController?.let { controller ->
controller.hide(WindowInsets.Type.systemBars())
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN)
}
}
}
@@ -0,0 +1,231 @@
package com.video.core.fragments
import android.annotation.SuppressLint
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.widget.FrameLayout
import android.widget.ImageButton
import androidx.activity.OnBackPressedCallback
import androidx.annotation.OptIn
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.video.core.utils.PictureInPictureUtils.createPictureInPictureParams
import com.video.view.VideoView
import java.util.UUID
@OptIn(UnstableApi::class)
class FullscreenVideoFragment(private val videoView: VideoView) : Fragment() {
val id: String = UUID.randomUUID().toString()
private var container: FrameLayout? = null
private var originalPlayerParent: ViewGroup? = null
private var originalPlayerLayoutParams: ViewGroup.LayoutParams? = null
private var rootContentViews: List<View> = listOf()
// Back press callback to handle back navigation
private val backPressCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
videoView.exitFullscreen()
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Create a fullscreen container
this.container = FrameLayout(requireContext()).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setBackgroundColor(android.graphics.Color.BLACK)
keepScreenOn = true
}
return this.container
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Register back press callback
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressCallback)
enterFullscreenMode()
setupPlayerView()
hideSystemUI()
// Update PiP params if supported
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val params = createPictureInPictureParams(videoView)
requireActivity().setPictureInPictureParams(params)
} catch (_: Exception) {}
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// Handle PiP mode changes
val isInPictureInPictureMode =
requireActivity().isInPictureInPictureMode
if (isInPictureInPictureMode) {
videoView.playerView.useController = false
} else {
videoView.playerView.useController = videoView.useController
}
}
private fun enterFullscreenMode() {
// Store original parent and layout params
originalPlayerParent = videoView.playerView.parent as? ViewGroup
originalPlayerLayoutParams = videoView.playerView.layoutParams
// Remove player from original parent
originalPlayerParent?.removeView(videoView.playerView)
// Hide all root content views
val currentActivity = requireActivity()
val rootContent = currentActivity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
rootContentViews = (0 until rootContent.childCount)
.map { rootContent.getChildAt(it) }
.filter { it.isVisible }
rootContentViews.forEach { view ->
view.visibility = View.GONE
}
// Add our fullscreen container to root
rootContent.addView(container,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
}
private fun setupPlayerView() {
// Add PlayerView to our container
container?.addView(videoView.playerView,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
)
videoView.playerView.setBackgroundColor(android.graphics.Color.BLACK)
videoView.playerView.setShutterBackgroundColor(android.graphics.Color.BLACK)
// We need show controls in fullscreen
videoView.playerView.useController = true
setupFullscreenButton()
videoView.playerView.setShowSubtitleButton(true)
}
@SuppressLint("PrivateResource")
private fun setupFullscreenButton() {
videoView.playerView.setFullscreenButtonClickListener { _ ->
videoView.exitFullscreen()
}
// Change icon to exit fullscreen
val button = videoView.playerView.findViewById<ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
button?.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_exit)
}
@Suppress("DEPRECATION")
private fun hideSystemUI() {
val currentActivity = requireActivity()
container?.let { container ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
container.fitsSystemWindows = false
container.windowInsetsController?.let { controller ->
controller.hide(WindowInsets.Type.systemBars())
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
currentActivity.window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN
)
}
}
}
@Suppress("DEPRECATION")
private fun restoreSystemUI() {
val currentActivity = requireActivity()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
container?.windowInsetsController?.show(WindowInsets.Type.systemBars())
} else {
currentActivity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
}
}
fun exitFullscreen() {
// Remove back press callback since we're exiting
backPressCallback.remove()
restoreSystemUI()
if (videoView.useController == false) {
videoView.playerView.useController = false
}
// Ensure PlayerView keeps black background when returning to normal mode
videoView.playerView.setBackgroundColor(android.graphics.Color.BLACK)
videoView.playerView.setShutterBackgroundColor(android.graphics.Color.BLACK)
// Remove PlayerView from our container
container?.removeView(videoView.playerView)
// Remove our container from root
val currentActivity = requireActivity()
val rootContent = currentActivity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
rootContent.removeView(container)
// Restore root content views
rootContentViews.forEach { it.visibility = View.VISIBLE }
rootContentViews = listOf()
// Safely restore PlayerView to original parent
// First, ensure PlayerView is removed from any current parent
val currentParent = videoView.playerView.parent as? ViewGroup
currentParent?.removeView(videoView.playerView)
// Now add it back to the original parent
originalPlayerParent?.addView(videoView.playerView, originalPlayerLayoutParams)
// Remove this fragment
parentFragmentManager.beginTransaction()
.remove(this)
.commitAllowingStateLoss()
// Notify VideoView that we've exited fullscreen
videoView.isInFullscreen = false
}
override fun onDestroy() {
super.onDestroy()
// Ensure we clean up properly if fragment is destroyed
if (videoView.isInFullscreen) {
exitFullscreen()
}
}
}
@@ -28,7 +28,6 @@ import com.margelo.nitro.core.Promise
import com.video.core.LibraryError
import com.video.core.PlayerError
import com.video.core.VideoManager
import com.video.core.activities.FullscreenVideoViewActivity
import com.video.core.player.OnAudioFocusChangedListener
import com.video.core.recivers.AudioBecomingNoisyReceiver
import com.video.core.utils.Threading.runOnMainThread
@@ -295,15 +294,6 @@ class HybridVideoPlayer() : HybridVideoPlayerSpec() {
}
}
fun moveToFullscreenActivity(activity: FullscreenVideoViewActivity) {
VideoManager.registerFullscreenActivity(activity, activity.hashCode())
runOnMainThreadSync {
PlayerView.switchTargetView(playerPointer, currentPlayerView?.get(), activity.playerView)
currentPlayerView = WeakReference(activity.playerView)
}
}
override val memorySize: Long
get() = allocator?.totalBytesAllocated?.toLong() ?: 0L
@@ -35,7 +35,7 @@ class HybridVideoViewViewManager(nitroId: Int): HybridVideoViewViewManagerSpec()
}
override fun exitFullscreen() {
throw LibraryError.MethodNotSupported("exitFullscreen")
videoView.get()?.exitFullscreen()
}
override fun enterPictureInPicture() {
@@ -2,7 +2,6 @@ package com.video.view
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.util.AttributeSet
@@ -22,7 +21,7 @@ import com.margelo.nitro.video.VideoViewEvents
import com.video.core.LibraryError
import com.video.core.VideoManager
import com.video.core.VideoViewError
import com.video.core.activities.FullscreenVideoViewActivity
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
@@ -108,6 +107,7 @@ class VideoView @JvmOverloads constructor(
}
private var rootContentViews: List<View> = listOf()
private var pictureInPictureHelperTag: String? = null
private var fullscreenFragmentTag: String? = null
val applicationContent: ReactApplicationContext
get() {
@@ -136,10 +136,14 @@ class VideoView @JvmOverloads constructor(
post(layoutRunnable)
}
@SuppressLint("PrivateResource")
private fun setupFullscreenButton() {
playerView.setFullscreenButtonClickListener { _ ->
enterFullscreen()
}
playerView.findViewById<ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
?.setImageResource(androidx.media3.ui.R.drawable.exo_ic_fullscreen_enter)
}
fun enterFullscreen() {
@@ -147,25 +151,54 @@ class VideoView @JvmOverloads constructor(
return
}
isInFullscreen = true
val intent = Intent(context, FullscreenVideoViewActivity::class.java)
intent.putExtra("nitroId", nitroId)
val currentActivity = applicationContent.currentActivity
if (currentActivity !is FragmentActivity) {
Log.e("ReactNativeVideo", "Current activity is not a FragmentActivity, cannot enter fullscreen")
return
}
try {
val currentActivity = applicationContent.currentActivity
currentActivity?.startActivity(intent)
events.willEnterFullscreen?.let { it() }
val fragment = FullscreenVideoFragment(this)
fullscreenFragmentTag = fragment.id
currentActivity.supportFragmentManager.beginTransaction()
.add(fragment, fragment.id)
.commitAllowingStateLoss()
isInFullscreen = true
} catch (err: Exception) {
val debugMessage = "Failed to start fullscreen activity for nitroId: $nitroId"
val debugMessage = "Failed to start fullscreen fragment for nitroId: $nitroId"
Log.e("ReactNativeVideo", debugMessage, err)
}
}
@SuppressLint("PrivateResource")
fun exitFullscreen() {
// Change fullscreen button icon back to enter fullscreen
playerView.findViewById<ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
?.setImageResource(androidx.media3.ui.R.drawable.exo_ic_fullscreen_enter)
if (!isInFullscreen) {
return
}
events.willExitFullscreen?.let { it() }
val currentActivity = applicationContent.currentActivity
fullscreenFragmentTag?.let { tag ->
(currentActivity as? FragmentActivity)?.let { activity ->
activity.supportFragmentManager.findFragmentByTag(tag)?.let { fragment ->
// The fragment will handle its own removal in exitFullscreen()
if (fragment is FullscreenVideoFragment) {
runOnMainThread {
fragment.exitFullscreen()
}
}
}
}
fullscreenFragmentTag = null
}
// Change fullscreen button icon back to enter fullscreen and update callback
setupFullscreenButton()
isInFullscreen = false
}
@@ -199,11 +232,28 @@ class VideoView @JvmOverloads constructor(
}
}
private fun removeFullscreenFragment() {
val currentActivity = applicationContent.currentActivity
fullscreenFragmentTag?.let { tag ->
(currentActivity as? FragmentActivity)?.let { activity ->
activity.supportFragmentManager.findFragmentByTag(tag)?.let { fragment ->
activity.supportFragmentManager.beginTransaction()
.remove(fragment)
.commitAllowingStateLoss()
}
}
fullscreenFragmentTag = null
}
}
fun hideRootContentViews() {
// Remove playerView from parent
// In PiP mode, we don't want to show the controller
// Controls are handled by System if we have MediaSession
playerView.useController = false
playerView.setBackgroundColor(Color.BLACK)
playerView.setShutterBackgroundColor(Color.BLACK)
(playerView.parent as? ViewGroup)?.removeView(playerView)
val currentActivity = applicationContent.currentActivity ?: return
@@ -225,6 +275,9 @@ class VideoView @JvmOverloads constructor(
// Reset PlayerView settings
playerView.useController = useController
playerView.setBackgroundColor(Color.BLACK)
playerView.setShutterBackgroundColor(Color.BLACK)
val currentActivity = applicationContent.currentActivity ?: return
val rootContent = currentActivity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
rootContent.removeView(playerView)
@@ -273,6 +326,7 @@ class VideoView @JvmOverloads constructor(
// -------- View Lifecycle Methods --------
override fun onDetachedFromWindow() {
removePipHelper()
removeFullscreenFragment()
VideoManager.unregisterView(this)
super.onDetachedFromWindow()
}
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/fullscreen_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
android:keepScreenOn="true"
tools:context=".core.activities.FullscreenVideoViewActivity">
<androidx.media3.ui.PlayerView
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>