androidreact-nativenativereact-native-modulesbitmovin-player

Pass onPictureInPictureModeChanged result into a react native module


I am trying to do some picture in picture mode using react-native. I wrote a react module

I need to generate something similar to this but inside the react native module

public class MainActivity extends AppCompatActivity {
    private PlayerView playerView;
    private Player player;
    private boolean playerShouldPause = true;

...

    @Override
    public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) {
        super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);

        // Hiding the ActionBar
        if (isInPictureInPictureMode) {
            getSupportActionBar().hide();
        } else {
            getSupportActionBar().show();
        }
        playerView.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
    }
...
}

There is some way to do it the same way but inside ReactContextBaseJavaModule

public class ReactNativeBitmovinPlayerModule extends ReactContextBaseJavaModule {

...
 @Override
    public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) {
        super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);

        // Hiding the ActionBar
        if (isInPictureInPictureMode) {
            getSupportActionBar().hide();
        } else {
            getSupportActionBar().show();
        }
        playerView.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
    }
...
}

Solution

  • Yes, it's possible to achieve it. And there's actually more than one way to listen for PiP mode events from native modules.

    Quick way

    The easiest way is by making your base java module a LifecycleStateObserver and checking changes on Activity.isInPictureInPictureMode() for every activity state update.

    public class ReactNativeCustomModule extends ReactContextBaseJavaModule implements LifecycleEventObserver {
      private boolean isInPiPMode = false;
      private final ReactApplicationContext reactContext;
    
      public ReactNativeCustomModule(ReactApplicationContext reactContext) {
        super(reactContext);
        this.reactContext = reactContext;
      }
    
      private void sendEvent(String eventName, @Nullable WritableMap args) {
        reactContext
          .getJSModule(RCTDeviceEventEmitter.class)
          .emit(eventName, args);
      }
    
      @ReactMethod
      public void registerLifecycleEventObserver() {
        AppCompatActivity activity = (AppCompatActivity) reactContext.getCurrentActivity();
        if (activity != null) {
          activity.getLifecycle().addObserver(this);
        } else {
          Log.d(this.getName(), "App activity is null.");
        }
      }
    
      @Override
      public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
          AppCompatActivity activity = (AppCompatActivity) source;
          boolean isInPiPMode = activity.isInPictureInPictureMode();
          // Check for changes on pip mode.
          if (this.isInPiPMode != isInPiPMode) {
            this.isInPiPMode = isInPiPMode;
            Log.d(this.getName(), "Activity pip mode has changed to " + isInPiPMode);
            // Dispatch onPictureInPicutreModeChangedEvent to js.
            WritableMap args = Arguments.createMap();
            args.putBoolean("isInPiPMode", isInPiPMode)
            sendEvent("onPictureInPictureModeChanged", args);
          }
        }
      }
      // ...
    }
    

    Note it's impossible to register the lifecycle observer inside the module's constructor because the activity's still null. It needs to be registered on the javascript side.

    Therefore, call registerLifecycleEventObserver at your component's initialization so it can start receiving activity state updates.

    import React, { useEffect } from 'react';
    import { NativeEventEmitter, NativeModules } from 'react-native';
    
    const ReactNativeCustomModule = NativeModules.ReactNativeCustomModule;
    const eventEmitter = new NativeEventEmitter(ReactNativeCustomModule);
    
    // JS wrapper.
    export const CustomComponent = () => {
      useEffect(() => {
        // Register module to receive activity's state updates.
        ReactNativeCustomModule.registerLifecycleEventObserver();
        const listener = eventEmitter.addListener('onPictureInPictureModeChanged', (args) => {
          console.log('isInPiPMode:', args.isInPiPMode);
        });
        return () => listener.remove();
      }, []);
    
      return (
        // jsx
      );
    };
    

    By the way, I opened a pull request on react-native-bitmovin-player implementing this very feature. Please, check it out 😉.

    Hard way

    There's yet another way to listen for PiP changes, but it's more complex and requires a deeper knowledge of both android and RN platforms. However, with it, you get the advantage of accessing newConfig on the onPictureInPictureModeChanged method (if required) and not listening to any of the activity's lifecycle events.

    Start by embedding your custom native view (whatever it is) into a Fragment, then override the fragment's onPictureInPictureModeChanged method and finally dispatch an RN event there. Here's how it can be done step by step:

    1. Create a fragment for your custom view:
    // Make sure to use android.app's version of Fragment if you need
    // to access the `newConfig` argument.
    import android.app.Fragment;
    
    // If not, use androidx.fragment.app's version.
    // This is the preferable way nowadays, but doesn't include `newConfig`.
    // import androidx.fragment.app.Fragment;
    
    // For the sake of example, lets use android.app's version here.
    public class CustomViewFragment extends Fragment {
      interface OnPictureInPictureModeChanged {
        void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig);
      }
    
      private CustomView customView;
      private OnPictureInPictureModeChanged listener;
    
      @Nullable
      @Override
      public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
        super.onCreateView(inflater, container, savedInstanceState);
        customView = new CustomView();
        // Do all UI setups needed for customView here.
        return customView;
      }
    
      // Android calls this method on the fragment everytime its activity counterpart is also called.
      @Override
      public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) {
        super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
        if (listener != null) {
          this.listener.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
        }
      }
    
      public void setOnPictureInPictureModeChanged(OnPictureInPictureModeChanged listener) {
        this.listener = listener;
      }
    
      // OnViewCreated, onPause, onResume, onDestroy...
    }
    
    1. Create an RN ViewGroupManager to hold the fragment and export functions and props to javascript:
    public class CustomViewManager extends ViewGroupManager<FrameLayout> implements CustomViewFragment.OnPictureInPictureModeChanged {
      public static final String REACT_CLASS = "CustomViewManager";
      public final int COMMAND_CREATE = 1;
    
      ReactApplicationContext reactContext;
    
      public CustomViewManager(ReactApplicationContext reactContext) {
        this.reactContext = reactContext;
      }
    
      // Expose `onPictureInPictureModeChanged` prop to javascript.  
      public Map getExportedCustomBubblingEventTypeConstants() {
        return MapBuilder.builder().put(
          "onPictureInPictureModeChanged",
          MapBuilder.of(
            "phasedRegistrationNames",
             MapBuilder.of("bubbled", "onPictureInPictureModeChanged")
          )
        ).build();
      }
    
      @Override
      public void onPictureInPictureModeChanged(boolean isInPiPMode, Configuration newConfig) {
        Log.d(this.getName(), "PiP mode changed to " + isInPiPMode + " with config " + newConfig.toString());
    
        // Dispatch onPictureInPictureModeChanged to js.
        final WritableMap args = Arguments.createMap();
        args.putBoolean("isInPiPMode", isInPiPMode);
        args.putMap("newConfig", asMap(newConfig));
        reactContext
          .getJSModule(RCTEventEmitter.class)
          .receiveEvent(getId(), "onPictureInPictureModeChanged", args);
      }
    
      // Get the JS representation of a Configuration object.
      private ReadableMap asMap(Configuration config) {
        final WritableMap map = Arguments.createMap();
        map.putBoolean("isNightModeActive", newConfig.isNightModeActive());
        map.putBoolean("isScreenHdr", newConfig.isScreenHdr());
        map.putBoolean("isScreenRound", newConfig.isScreenRound());
        // ...
        return map;
      }
    
      @Override
      public String getName() {
        return REACT_CLASS;
      }
    
      @Override
      public FrameLayout createViewInstance(ThemedReactContext reactContext) {
        return new FrameLayout(reactContext);
      }
    
      // Map the "create" command to an integer
      @Nullable
      @Override
      public Map<String, Integer> getCommandsMap() {
        return MapBuilder.of("create", COMMAND_CREATE);
      }
    
      // Handle "create" command (called from JS) and fragment initialization
      @Override
      public void receiveCommand(@NonNull FrameLayout root, String commandId, @Nullable ReadableArray args) {
        super.receiveCommand(root, commandId, args);
        int reactNativeViewId = args.getInt(0);
        int commandIdInt = Integer.parseInt(commandId);
        switch (commandIdInt) {
          case COMMAND_CREATE:
            createFragment(root, reactNativeViewId);
            break;
          default: {}
        }
      }
    
      // Replace RN's underlying native view with your own
      public void createFragment(FrameLayout root, int reactNativeViewId) {
        ViewGroup parentView = (ViewGroup) root.findViewById(reactNativeViewId).getParent();
        // You'll very likely need to manually layout your parent view as well to make sure
        // it stays updated with the props from RN.
        //
        // I recommend taking a look at android's `view.Choreographer` and  RN's docs on how to do it.
        // And, as I said, this requires some knowledge of native Android UI development.
        setupLayout(parentView);
    
        final CustomViewFragment fragment = new CustomViewFragment();
        fragment.setOnPictureInPictureModeChanged(this);
    
        FragmentActivity activity = (FragmentActivity) reactContext.getCurrentActivity();
        // Make sure to use activity.getSupportFragmentManager() if you're using
        // androidx's Fragment.
        activity.getFragmentManager()
          .beginTransaction()
          .replace(reactNativeViewId, fragment, String.valueOf(reactNativeViewId))
          .commit();
      }
    }
    
    1. Register CustomViewManager in a package:
    public class CustomPackage implements ReactPackage {
      @Override
      public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
        return Arrays.<ViewManager>asList(
          new CustomViewManager(reactContext)
        );
      }
    }
    
    1. Implement the javascript side:
    import React, { useEffect, useRef } from 'react';
    import { UIManager, findNodeHandle, requireNativeComponent } from 'react-native';
    
    const CustomViewManager = requireNativeComponent('CustomViewManager');
    
    const createFragment = (viewId) =>
      UIManager.dispatchViewManagerCommand(
        viewId,
        UIManager.CustomViewManager.Commands.create.toString(), // we are calling the 'create' command
        [viewId]
      );
    
    export const CustomView = ({ style }) => {
      const ref = useRef(null);
    
      useEffect(() => {
        const viewId = findNodeHandle(ref.current);
        createFragment(viewId!);
      }, []);
    
      const onPictureInPictureModeChanged = (event) => {
        console.log('isInPiPMode:', event.nativeEvent.isInPiPMode);
        console.log('newConfig:', event.nativeEvent.newConfig);
      }
    
      return (
        <CustomViewManager
          style={{
            ...(style || {}),
            height: style && style.height !== undefined ? style.height || '100%',
            width: style && style.width !== undefined ? style.width || '100%'
          }}
          ref={ref}
          onPictureInPictureModeChanged={onPictureInPictureModeChanged}
        />
      );
    };
    

    This last example was heavily based on RN's documentation. And I can't stress enough how important it is to read it if you go the hard way.

    Anyways, I hope this little guide may be of help.

    Best regards.