yor-dev

React Native Picture-in-Picture Native in Android

abril 22, 2025 | by yorland

Background_doggies

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.