React Native Picture-in-Picture Native in Android
abril 22, 2025 | by yorland


Muchas librerías y ninguna te funciona?
Hace poco me vi en la necesidad de implementar picture-in-picture en Android con React Native, pero ninguna de las librerías que encontre me funcionaban, ó hacían la funcionalidad a medias ó ya no tenian soporte ó daban mas problemas que soluciones.
Asi que, porque no hacer yo mismo una libreria?, suena bien pero mejor seria enseñar como crear un componente nativo y asi puedan agregar o quitar las funcionalidad que quieran.
Requisitos previos
node 20.17.0+
Android Studio y SDK 26+
Instalación
Vamos desde el principio, a generar un proyecto nuevo de react native
Dependiendo de tu configuracion es posible que falle los comandos para crear un proyecto de React Native desde cero (No lo hacemos con expo ya que necesitamos la carpeta de Android generada), te dejo estos 4 ejemplos y cual me funciono (el 4to):
npx react-native@latest init MyPipCustom
yarn create react-native@latest MyPipCustom
npm init react-native@latest MyPipCustom
Si ninguno de estos te funciona, prueba con desinstalar de forma global react-native-cli
npm uninstall -g react-native-cli
Y luego:
npx @react-native-community/cli init MyPipCustom
Una vez terminado (RN .79), vayamos a nuestro proyecto e instalemos las librerias:
cd MyPipCustom && yarn install
Con todo instalado, estamos listos para comenzar!
Creamos el componente PipScreen en nuestra carpeta src/ que va tener la logica para llamar al pip nativo desde React Native.
- src/pipScreen/PipScreen.tsx
import {useEffect} from 'react';
import {
NativeEventEmitter,
NativeModules,
AppState,
View,
Text,
TouchableOpacity,
ImageBackground,
} from 'react-native';
const PipScreen = () => {
// If you need more control you can create a Context to allow navigation within your app
const {NativePipModule} = NativeModules;
// Event emitter to know when the user exits the PiP window
const pipEventEmitter = new NativeEventEmitter(NativePipModule);
useEffect(() => {
const exitSubscription = pipEventEmitter.addListener('onExitPip', () => {
// If the app is in the background, bring it to the front via native call
if (AppState.currentState === 'background') {
NativePipModule.bringAppToFront();
}
});
const destroyedSubscription = pipEventEmitter.addListener(
'onPipDestroyed',
() => {
// Handle PiP activity destruction if needed
console.log('PiP activity was destroyed');
},
);
return () => {
exitSubscription.remove();
destroyedSubscription.remove();
};
}, []);
return (
<View>
<TouchableOpacity
onPress={() => {
NativePipModule?.showPIP(
'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
);
}}>
<ImageBackground
source={require('../../assets/big_bunny_poster.png')}
resizeMode="cover"
style={{
justifyContent: 'center',
width: '100%',
height: 200,
}}
/>
<Text style={{textAlign: 'center', marginTop: 20}}>
Click on the image to enter PiP
</Text>
</TouchableOpacity>
</View>
);
};
export default PipScreen;
La propiedad NativeModules es el puente que se encarga de manejar los componentes creados en codigo nativo y traerlo a nuestro proyecto de React Native
La funcion NativeEventEmitter nos permite crear Listeners para escuchar cualquier cosa que se envie desde el codigo Nativo, por ejemplo, para avisar que se cerro la ventana de PiP y es necesario abrir la app (si esta estuviera minimizada)
la funcion NativePipModule.showPIP() sera nuestra funcion que levantara el PiP nativo de Android desde React Native, cuando le demos click al thumbnails del video.
Ahora, vayamos al codigo nativo de Android
Vamos a crear varios archivos para la implementacion del Picture-in-Picture:
- NativePipModule.tk (android/app/src/main/java/your/package/NativePipModule.kt )
Aqui tendremos la logica que conectara nuestro codigo de react-native con el codigo nativo, nuestro puente.
package com.mypipcustom
import android.app.PictureInPictureParams
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.util.Rational
import android.widget.VideoView
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import android.util.Log
import android.content.Intent
import com.facebook.react.modules.core.DeviceEventManagerModule
class NativePipModule(private val reactContext: ReactApplicationContext)
: ReactContextBaseJavaModule(reactContext) {
init {
// Save the ReactApplicationContext instance for use in the companion object
instance = reactContext
}
override fun getName() = "NativePipModule"
@ReactMethod
fun showPIP(urlVideo: String) {
val activity = currentActivity
// Check if the PiP WebView activity is already running
if (activity is WebViewCustomActivity) {
// Activity is already WebViewCustomActivity
// You can recall the Pip with another video here
} else {
// Launch WebViewCustomActivity to start PiP mode
val intent = Intent(activity, WebViewCustomActivity::class.java)
intent.putExtra("url", urlVideo)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
activity?.startActivity(intent)
return
}
}
@ReactMethod
fun bringAppToFront() {
val activity = currentActivity
if (activity != null) {
// If an activity is in the foreground, lets call the MainActivity
val intent = Intent(activity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_NEW_TASK
}
activity.startActivity(intent)
} else {
// If no current activity (app in background), get the launch Intent and start it
val packageManager = reactApplicationContext.packageManager
val launchIntent = packageManager
.getLaunchIntentForPackage(reactApplicationContext.packageName)
launchIntent?.addFlags(
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_NEW_TASK
)
reactApplicationContext.startActivity(launchIntent)
}
}
@ReactMethod
fun addListener(eventName: String) {
// No-op: required to prevent large warning messages in debug
}
@ReactMethod
fun removeListeners(count: Int) {
// No-op: required to prevent large warning messages in debug
}
companion object {
// Store the ReactApplicationContext for sending events to JS
private var instance: ReactApplicationContext? = null
fun sendEvent(eventName: String, params: Any?) {
instance
?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
?.emit(eventName, params)
}
}
}
Aqui es donde la magia comienza, en la funcion showPIP() que se llamara desde react native, lo que hacemos es llamar a otra activity exclusiva para el Picture-in-Picture y le mandamos la url del video (urlVideo )
La funcion bringAppToFront() sirve para que cuando la app este minimizada y se cierre el PiP, la app escuche este evento y se levante siempre la actividad principal (que es nuestra app).
la funcion sendEvent() se encargara de mandar evento desde el codigo nativo a react-native, para por ejemplo, avisar que se cerro el PiP o si hubo algun error y debe de levantarse la app principal.
NativePipPackage.kt (android/app/src/main/java/com/mypipcustom/NativePipPackage.kt)
Este archivo se encarga de publicar nuestra puente en android
package com.mypipcustom
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ReactShadowNode
import com.facebook.react.uimanager.ViewManager
class NativePipPackage : ReactPackage {
override fun createViewManagers(
reactContext: ReactApplicationContext
): MutableList<ViewManager<*, *>> = mutableListOf()
override fun createNativeModules(
reactContext: ReactApplicationContext
): MutableList<NativeModule> = listOf<NativeModule>(NativePipModule(reactContext)).toMutableList()
}
WebViewCustomActivity.kt (android/app/src/main/java/com/mypipcustom/WebViewCustomActivity.kt)
Aqui es donde podremos llamar a nuestro layout para levantar el webView nativo y mostrar nuestro Picture-in-picture.
package com.mypipcustom
import android.app.PictureInPictureParams
import android.content.res.Configuration
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Rational
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity
class WebViewCustomActivity : AppCompatActivity() {
private lateinit var webView: WebView
companion object {
var instance: WebViewCustomActivity? = null
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Store the instance for use in NativePipModule
instance = this
setContentView(R.layout.activity_pip)
// Initialize WebView and enable settings
webView = findViewById(R.id.video_view_pip)
webView.webViewClient = WebViewClient()
webView.settings.javaScriptEnabled = true // Enable JavaScript if needed
webView.settings.mediaPlaybackRequiresUserGesture = false
// Load the URL (can be passed via Intent)
val url = intent.getStringExtra("url") ?: ""
webView.loadUrl(url)
// On Android versions below 14, immediately enter PiP
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val aspectRatio = Rational(16, 9)
val pipParams = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
.build()
enterPictureInPictureMode(pipParams)
}
}
// Enter PiP on Android 14+ with auto-enter enabled
private fun enterPip() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val aspectRatio = Rational(16, 9)
val pipParams = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
.setAutoEnterEnabled(true) // Android 14 feature
.build()
enterPictureInPictureMode(pipParams)
}
}
// Close the PiP activity from native module
fun closePipActivity() {
instance?.finish() // Finish the PiP activity
}
override fun onDestroy() {
// PiP has been destroyed
// Notify React Native that PiP was destroyed
NativePipModule.sendEvent("onPipDestroyed", null)
super.onDestroy()
}
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
if (!isInPictureInPictureMode) {
// PiP mode has closed: notify React Native and return to main Activity
NativePipModule.sendEvent("onExitPip", null)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
startActivity(intent)
finish()
}
}
override fun onResume() {
super.onResume()
// On Android 14+, re-enter PiP if needed
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
enterPip()
}
}
}
Desde nuestra funcion showPIP() que se encontraba en el archivo NativePipModule, se invoca a la activity WebViewCustomActivity (mientras que la app se ejecuta en el MainActivity, esta es la magia tener la app principal y el pip separados en 2 activities), en este archivo siguiendo el orden de las metodos que tenemos, entramos en onCreate() donde buscaremos nuestro layout
activity_pip.xml, que tiene un webView en el cual colocaremos nuestro urlVideo y le haremos un cambio de aspecto a ese layout para que sea transforme en un rectangulo pequeña para el Picture-in-picture.
Nuestra funcion onPictureInPictureModeChanged() sera la encargada de enviar los eventos a nuestro codigo en react-native y a su vez de cerrar la ventana del PiP cuando el usuario le de cerrar.
activity_pip.xml (android/app/src/main/res/layout/activity_pip.xml)
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- WebView used for playing video in PiP mode -->
<WebView
android:id="@+id/video_view_pip"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</RelativeLayout>
Aqui se va a mostrar el Pip, evitando llamar a la main activity que es donde esta la app principal)
AndroidManifest.xml (importante registrar la actividad para el picture-in-picture )
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".WebViewCustomActivity"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
android:launchMode="singleTask"
android:resizeableActivity="true"
android:supportsPictureInPicture="true"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" >
</activity>
</application>
</manifest>
Y por ultimo modificar nuestro archivo MainApplication.kt para registrar nuestro package.
package com.mypipcustom
import android.app.Application
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import com.mypipcustom.NativePipPackage
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost =
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
add(NativePipPackage())
}
override fun getJSMainModuleName(): String = "index"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
}
override val reactHost: ReactHost
get() = getDefaultReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
SoLoader.init(this, OpenSourceMergedSoMapping)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
}
}
}
Con todo creado y configurado, solo queda levantar la app:
npx react-native run-android
Nota: en cada modificacion dentro de la carpeta android, lo ideal seria remover la aplicacion del dispositivo o emulador y re-instalar con el comando anterior.