diff --git a/.pubignore b/.pubignore index c59a4a52b..86263a312 100644 --- a/.pubignore +++ b/.pubignore @@ -1,3 +1,6 @@ integration_test_app/ tool/ -test_shard/ \ No newline at end of file +test_shard/ +docs/ +ci/ +scripts/ \ No newline at end of file diff --git a/README.md b/README.md index 6b5e7b36d..6c3c02517 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,10 @@ Please refer to the [Flutter documentation](https://docs.flutter.dev/platform-in * [Windows](https://api-ref.agora.io/en/video-sdk/cpp/4.x/API/rtc_api_overview_ng.html) * [Web](https://api-ref.agora.io/en/video-sdk/web/4.x/index.html) +## Integration document + +* [Picture-in-Picture](docs/integration/Picture-in-Picture.md) + ## Feedback If you have any problems or suggestions regarding the sample projects, feel free to file an [issue](https://github.com/AgoraIO-Community/agora_rtc_engine/issues) OR pull request. diff --git a/android/build.gradle b/android/build.gradle index 48ab715ef..73e7bb856 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -65,6 +65,8 @@ dependencies { api 'io.agora.rtc:agora-special-full:4.3.2.11' api 'io.agora.rtc:full-screen-sharing:4.3.2.11' // native dependencies end + + api 'io.agora.rtc:pip:0.0.1-rc.1' } } diff --git a/android/src/main/java/io/agora/agora_rtc_ng/AgoraPIPFlutterActivity.java b/android/src/main/java/io/agora/agora_rtc_ng/AgoraPIPFlutterActivity.java new file mode 100644 index 000000000..5ef3b306c --- /dev/null +++ b/android/src/main/java/io/agora/agora_rtc_ng/AgoraPIPFlutterActivity.java @@ -0,0 +1,106 @@ +package io.agora.agora_rtc_ng; + +import io.flutter.embedding.android.FlutterActivity; + +import android.app.PictureInPictureParams; +import android.app.PictureInPictureUiState; +import android.content.Context; +import android.content.res.Configuration; + +import java.lang.ref.WeakReference; + +import io.agora.pip.AgoraPIPActivityProxy; +import io.agora.pip.AgoraPIPActivityListener; + +public class AgoraPIPFlutterActivity extends FlutterActivity implements AgoraPIPActivityProxy { + private WeakReference mListener; + + @Override + public Context getApplicationContext() { + return super.getApplicationContext(); + } + + @Override + public void setAgoraPIPActivityListener(AgoraPIPActivityListener listener) { + mListener = new WeakReference<>(listener); + } + + @Override + public boolean isInPictureInPictureMode() { + return super.isInPictureInPictureMode(); + } + + @Override + public void setPictureInPictureParams(PictureInPictureParams params) { + super.setPictureInPictureParams(params); + } + + @Override + public boolean enterPictureInPictureMode(PictureInPictureParams params) { + return super.enterPictureInPictureMode(params); + } + + @Override + public void enterPictureInPictureMode() { + super.enterPictureInPictureMode(); + } + + @Override + public boolean moveTaskToBack(boolean nonRoot) { + return super.moveTaskToBack(nonRoot); + } + + @Override + public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, + Configuration newConfig) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); + if (mListener == null) { + return; + } + + AgoraPIPActivityListener listener = mListener.get(); + if (listener != null) { + listener.onPictureInPictureModeChanged(isInPictureInPictureMode, + newConfig); + } + } + + @Override + public boolean onPictureInPictureRequested() { + if (mListener != null) { + AgoraPIPActivityListener listener = mListener.get(); + if (listener != null) { + return listener.onPictureInPictureRequested(); + } + } + + + return super.onPictureInPictureRequested(); + } + + @Override + public void onPictureInPictureUiStateChanged(PictureInPictureUiState state) { + super.onPictureInPictureUiStateChanged(state); + if (mListener == null) { + return; + } + + AgoraPIPActivityListener listener = mListener.get(); + if (listener != null) { + listener.onPictureInPictureUiStateChanged(state); + } + } + + @Override + public void onUserLeaveHint() { + super.onUserLeaveHint(); + if (mListener == null) { + return; + } + + AgoraPIPActivityListener listener = mListener.get(); + if (listener != null) { + listener.onUserLeaveHint(); + } + } +} diff --git a/android/src/main/java/io/agora/agora_rtc_ng/AgoraPipActivity.java b/android/src/main/java/io/agora/agora_rtc_ng/AgoraPipActivity.java deleted file mode 100644 index 459025acf..000000000 --- a/android/src/main/java/io/agora/agora_rtc_ng/AgoraPipActivity.java +++ /dev/null @@ -1,67 +0,0 @@ -package io.agora.agora_rtc_ng; - -import android.app.PictureInPictureUiState; -import android.content.res.Configuration; -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; - -import io.flutter.embedding.android.FlutterActivity; - -@RequiresApi(Build.VERSION_CODES.O) -public class AgoraPipActivity extends FlutterActivity { - public interface AgoraPipActivityListener { - void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig); - - void onPictureInPictureUiStateChanged(PictureInPictureUiState state); - - boolean onPictureInPictureRequested(); - - void onUserLeaveHint(); - } - - private AgoraPipActivityListener mListener; - - public void setAgoraPipActivityListener(AgoraPipActivityListener listener) { - mListener = listener; - } - - // only available in API level 26 and above - @RequiresApi(26) - @Override - public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); - if (mListener != null) { - mListener.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); - } - } - - // only available in API level 30 and above - @RequiresApi(30) - @Override - public boolean onPictureInPictureRequested() { - if (mListener != null) { - return mListener.onPictureInPictureRequested(); - } - return super.onPictureInPictureRequested(); - } - - // only available in API level 31 and above - @RequiresApi(31) - @Override - public void onPictureInPictureUiStateChanged(@NonNull PictureInPictureUiState state) { - super.onPictureInPictureUiStateChanged(state); - if (mListener != null) { - mListener.onPictureInPictureUiStateChanged(state); - } - } - - @Override - public void onUserLeaveHint() { - super.onUserLeaveHint(); - if (mListener != null) { - mListener.onUserLeaveHint(); - } - } -} diff --git a/android/src/main/java/io/agora/agora_rtc_ng/AgoraPipController.java b/android/src/main/java/io/agora/agora_rtc_ng/AgoraPipController.java deleted file mode 100644 index 32d4548bb..000000000 --- a/android/src/main/java/io/agora/agora_rtc_ng/AgoraPipController.java +++ /dev/null @@ -1,387 +0,0 @@ -package io.agora.agora_rtc_ng; - -import android.app.Activity; -import android.app.PictureInPictureParams; -import android.app.PictureInPictureUiState; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.graphics.Rect; -import android.os.Build; -import android.os.Handler; -import android.os.Looper; -import android.util.Rational; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; - -import java.lang.ref.WeakReference; -import java.util.Objects; - -@RequiresApi(Build.VERSION_CODES.O) -public class AgoraPipController - implements AgoraPipActivity.AgoraPipActivityListener { - public enum PipState { - Started(0), - Stopped(1), - Failed(2); - - private final int value; - - PipState(int value) { - this.value = value; - } - - public int getValue() { - return value; - } - } - - public interface PipStateChangedListener { - void onPipStateChangedListener(PipState state); - } - - private static class PipParams { - @Nullable - private final Rational aspectRatio; - @Nullable - private final Boolean autoEnterEnabled; - @Nullable - private final Rect sourceRectHint; - @Nullable - private final Boolean seamlessResizeEnabled; - @Nullable - private final Boolean useExternalStateMonitor; - @Nullable - private final Integer externalStateMonitorInterval; - - public PipParams(@Nullable Rational aspectRatio, - @Nullable Boolean autoEnterEnabled, - @Nullable Rect sourceRectHint, - @Nullable Boolean seamlessResizeEnabled, - @Nullable Boolean useExternalStateMonitor, - @Nullable Integer externalStateMonitorInterval) { - this.aspectRatio = aspectRatio; - this.autoEnterEnabled = autoEnterEnabled; - this.sourceRectHint = sourceRectHint; - this.seamlessResizeEnabled = seamlessResizeEnabled; - this.useExternalStateMonitor = useExternalStateMonitor; - this.externalStateMonitorInterval = externalStateMonitorInterval; - } - } - - private boolean mIsSupported = false; - private boolean mIsAutoEnterSupported = false; - private PipParams mPipParams; - private PictureInPictureParams.Builder mParamsBuilder; - private WeakReference mActivity; - private final PipStateChangedListener mListener; - // Note: The interval is set to 100ms to reduce the flickering effect. - // This is necessary because Flutter's activity lifecycle events don't include - // PiP state transitions, so we rely on polling to detect changes. - private static final long CHECK_INTERVAL_MS = 100; - private Handler mHandler; - private Runnable mCheckStateTask; - private boolean mLastPipState = false; - - public AgoraPipController(@NonNull Activity activity, - @Nullable PipStateChangedListener listener) { - setActivity(activity); - - mListener = listener; - mHandler = new Handler(Looper.getMainLooper()); - } - - private void checkPipState() { - boolean currentState = isActivated(); - if (currentState != mLastPipState) { - mLastPipState = currentState; - notifyPipStateChanged(currentState ? PipState.Started : PipState.Stopped); - } - } - - private boolean isPipEnabled() { - return mPipParams != null && mParamsBuilder != null; - } - - private void notifyPipStateChanged(PipState state) { - if (mListener != null) { - mListener.onPipStateChangedListener(state); - } - } - - private boolean checkPipSupport() { - Activity activity = mActivity.get(); - if (activity == null) { - return false; - } - - // only support android 8 and above - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - return false; - } - - final PackageManager pm = - activity.getApplicationContext().getPackageManager(); - if (pm == null) { - return false; - } - - return pm.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE); - } - - private boolean checkAutoEnterSupport() { - // Android 12 and above support to set auto enter enabled directly - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - return true; - } - - // For android 11 and below, we need to check if the activity is kind of - // AgoraPipActivity since we can enter pip mode when the onUserLeaveHint is - // called to enter pip mode as a workaround - Activity activity = mActivity.get(); - return activity instanceof AgoraPipActivity; - } - - private void setActivity(Activity activity) { - mActivity = new WeakReference<>(activity); - if (activity instanceof AgoraPipActivity) { - ((AgoraPipActivity) activity).setAgoraPipActivityListener(this); - } - - mIsSupported = checkPipSupport(); - mIsAutoEnterSupported = checkAutoEnterSupport(); - } - - public void attachToActivity(@NonNull Activity activity) { - setActivity(activity); - } - - public boolean isSupported() { - return mIsSupported; - } - - public boolean isAutoEnterSupported() { - return mIsAutoEnterSupported; - } - - public boolean isActivated() { - Activity activity = mActivity.get(); - if (activity == null) { - return false; - } - - // for android 7 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - return isSupported() && activity.isInPictureInPictureMode(); - } - - return false; - } - - public boolean setup(@Nullable Rational aspectRatio, - @Nullable Boolean autoEnterEnabled, - @Nullable Rect sourceRectHint, - @Nullable Boolean seamlessResizeEnabled, - @Nullable Boolean useExternalStateMonitor, - @Nullable Integer externalStateMonitorInterval) { - if (!isSupported()) { - return false; - } - - Activity activity = mActivity.get(); - if (activity == null) { - return false; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (mParamsBuilder == null) { - mParamsBuilder = new PictureInPictureParams.Builder(); - } - - if (mPipParams == null || - !Objects.equals(mPipParams.aspectRatio, aspectRatio) || - !Objects.equals(mPipParams.autoEnterEnabled, autoEnterEnabled) || - !Objects.equals(mPipParams.sourceRectHint, sourceRectHint) || - !Objects.equals(mPipParams.seamlessResizeEnabled, seamlessResizeEnabled) || - !Objects.equals(mPipParams.useExternalStateMonitor, useExternalStateMonitor) || - !Objects.equals(mPipParams.externalStateMonitorInterval, externalStateMonitorInterval)) { - mPipParams = - new PipParams(aspectRatio, autoEnterEnabled, sourceRectHint, - seamlessResizeEnabled, useExternalStateMonitor, - externalStateMonitorInterval); - } - - if (mPipParams.aspectRatio != null) { - mParamsBuilder.setAspectRatio(mPipParams.aspectRatio); - } - - // Note: setAutoEnterEnabled will not work if the target Android version - // is 11 or lower - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - mParamsBuilder.setAutoEnterEnabled( - Boolean.TRUE.equals(mPipParams.autoEnterEnabled)); - } - - if (mPipParams.sourceRectHint != null) { - mParamsBuilder.setSourceRectHint(mPipParams.sourceRectHint); - } - - // Disables the seamless resize. The seamless resize works great for - // videos where the content can be arbitrarily scaled, but you can disable - // this for non-video content so that the picture-in-picture mode is - // resized with a cross fade animation. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && mPipParams.seamlessResizeEnabled != null) { - mParamsBuilder.setSeamlessResizeEnabled( - Boolean.TRUE.equals(mPipParams.seamlessResizeEnabled)); - } - - activity.setPictureInPictureParams(mParamsBuilder.build()); - } - - // Start monitoring PIP state after setup - startStateMonitoring(); - - return true; - } - - public boolean start() { - if (!isSupported()) { - return false; - } - - if (isActivated()) { - return true; - } - - Activity activity = mActivity.get(); - if (activity == null) { - return false; - } - - if (!isPipEnabled()) { - return false; - } - - boolean bRes = true; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - bRes = activity.enterPictureInPictureMode(mParamsBuilder.build()); - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - activity.enterPictureInPictureMode(); - } - - return bRes; - } - - public void stop() { - if (!isSupported() || !isActivated()) { - return; - } - - Activity activity = mActivity.get(); - if (activity == null) { - return; - } - - // this will not stop the pip, it will just move the activity to the - // background and the pip will still be active when the activity is resumed - activity.moveTaskToBack(false); - } - - public void dispose() { - stopStateMonitoring(); - - // do not call stop() here, coz there is no truly stop in android, the - // implement of stop() is just moveTaskToBack(false), which is not what we - // want. - // stop(); - - Activity activity = mActivity.get(); - if (activity != null) { - // only call setPictureInPictureParams to clear flag setAutoEnterEnabled - // when setAutoEnterEnabled is supported - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - activity.setPictureInPictureParams(new PictureInPictureParams.Builder() - .setAutoEnterEnabled(false) - .build()); - } - } - - mPipParams = null; - mParamsBuilder = null; - mHandler = null; - mLastPipState = false; - mCheckStateTask = null; - } - - private void startStateMonitoring() { - // Do not need to monitor the pip state with external thread when the - // activity is kind of AgoraPipActivity and the external state monitor is - // not enabled - if (mActivity.get() instanceof AgoraPipActivity && - !Boolean.TRUE.equals(mPipParams.useExternalStateMonitor)) { - return; - } - - if (mHandler == null) { - mHandler = new Handler(Looper.getMainLooper()); - } - - // If task is already running, don't create a new one - if (mCheckStateTask != null) { - return; - } - - mCheckStateTask = new Runnable() { - @Override - public void run() { - checkPipState(); - mHandler.postDelayed( - this, mPipParams.externalStateMonitorInterval != null - ? mPipParams.externalStateMonitorInterval.longValue() - : CHECK_INTERVAL_MS); - } - }; - mHandler.post(mCheckStateTask); - } - - private void stopStateMonitoring() { - if (mHandler != null && mCheckStateTask != null) { - mHandler.removeCallbacks(mCheckStateTask); - } - } - - @Override - public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, - Configuration newConfig) { - if (Boolean.TRUE.equals(mPipParams.useExternalStateMonitor)) { - return; - } - - if (isInPictureInPictureMode) { - notifyPipStateChanged(PipState.Started); - } else { - notifyPipStateChanged(PipState.Stopped); - } - } - - @Override - public boolean onPictureInPictureRequested() { - return false; - } - - @Override - public void onPictureInPictureUiStateChanged(PictureInPictureUiState state) { - // do nothing for now - } - - @Override - public void onUserLeaveHint() { - // Only need to handle auto enter pip for android version below 12 - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - if (Boolean.TRUE.equals(mPipParams.autoEnterEnabled)) { - start(); - } - } - } -} diff --git a/android/src/main/java/io/agora/agora_rtc_ng/AgoraRtcNgPlugin.java b/android/src/main/java/io/agora/agora_rtc_ng/AgoraRtcNgPlugin.java index 5e5d51c0d..33652ed4a 100644 --- a/android/src/main/java/io/agora/agora_rtc_ng/AgoraRtcNgPlugin.java +++ b/android/src/main/java/io/agora/agora_rtc_ng/AgoraRtcNgPlugin.java @@ -1,5 +1,6 @@ package io.agora.agora_rtc_ng; +import android.app.Activity; import android.content.Context; import android.graphics.Rect; import android.os.Build; @@ -8,49 +9,52 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import java.io.IOException; -import java.lang.ref.WeakReference; -import java.util.HashMap; -import java.util.Map; - +import io.agora.pip.AgoraPIPActivityProxy; +import io.agora.pip.AgoraPIPController; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.embedding.engine.plugins.activity.ActivityAware; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; -public class AgoraRtcNgPlugin implements FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware { +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.util.HashMap; +import java.util.Map; + +public class AgoraRtcNgPlugin + implements FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware { private MethodChannel channel; private WeakReference flutterPluginBindingRef; private VideoViewController videoViewController; - private AgoraPipController pipController; + private AgoraPIPController pipController; @Nullable private Context applicationContext; @Override - public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { + public void + onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { applicationContext = flutterPluginBinding.getApplicationContext(); - channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "agora_rtc_ng"); + channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), + "agora_rtc_ng"); channel.setMethodCallHandler(this); flutterPluginBindingRef = new WeakReference<>(flutterPluginBinding); - videoViewController = new VideoViewController( - flutterPluginBinding.getTextureRegistry(), - flutterPluginBinding.getBinaryMessenger()); + videoViewController = + new VideoViewController(flutterPluginBinding.getTextureRegistry(), + flutterPluginBinding.getBinaryMessenger()); flutterPluginBinding.getPlatformViewRegistry().registerViewFactory( "AgoraTextureView", new AgoraPlatformViewFactory( - "AgoraTextureView", - flutterPluginBinding.getBinaryMessenger(), + "AgoraTextureView", flutterPluginBinding.getBinaryMessenger(), new AgoraPlatformViewFactory.PlatformViewProviderTextureView(), this.videoViewController)); flutterPluginBinding.getPlatformViewRegistry().registerViewFactory( "AgoraSurfaceView", new AgoraPlatformViewFactory( - "AgoraSurfaceView", - flutterPluginBinding.getBinaryMessenger(), + "AgoraSurfaceView", flutterPluginBinding.getBinaryMessenger(), new AgoraPlatformViewFactory.PlatformViewProviderSurfaceView(), this.videoViewController)); } @@ -63,12 +67,14 @@ public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { } @Override - public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + public void onMethodCall(@NonNull MethodCall call, + @NonNull MethodChannel.Result result) { if ("getAssetAbsolutePath".equals(call.method)) { getAssetAbsolutePath(call, result); } else if ("androidInit".equals(call.method)) { - // dart ffi DynamicLibrary.open do not trigger JNI_OnLoad in iris, so we need call java - // System.loadLibrary here to trigger the JNI_OnLoad explicitly. + // dart ffi DynamicLibrary.open do not trigger JNI_OnLoad in iris, so we + // need call java System.loadLibrary here to trigger the JNI_OnLoad + // explicitly. System.loadLibrary("AgoraRtcWrapper"); result.success(true); @@ -79,52 +85,74 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result } } - private void getAssetAbsolutePath(MethodCall call, MethodChannel.Result result) { + private void getAssetAbsolutePath(MethodCall call, + MethodChannel.Result result) { final String path = call.arguments(); if (path != null) { - if (this.flutterPluginBindingRef.get() != null - ) { + if (this.flutterPluginBindingRef.get() != null) { final String assetKey = this.flutterPluginBindingRef.get() .getFlutterAssets() .getAssetFilePathByName(path); try { - this.flutterPluginBindingRef.get().getApplicationContext() + this.flutterPluginBindingRef.get() + .getApplicationContext() .getAssets() .openFd(assetKey) .close(); result.success("/assets/" + assetKey); return; } catch (IOException e) { - result.error(e.getClass().getSimpleName(), e.getMessage(), e.getCause()); + result.error(e.getClass().getSimpleName(), e.getMessage(), + e.getCause()); return; } } } - result.error("IllegalArgumentException", "The parameter should not be null", null); + result.error("IllegalArgumentException", "The parameter should not be null", + null); } private void initPipController(@NonNull ActivityPluginBinding binding) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (pipController == null) { - - pipController = new AgoraPipController(binding.getActivity(), new AgoraPipController.PipStateChangedListener() { - @Override - public void onPipStateChangedListener(AgoraPipController.PipState state) { - // put state into a json object - channel.invokeMethod("pipStateChanged", new HashMap() {{ - put("state", state.getValue()); - }}); - } - }); - } else { - pipController.attachToActivity(binding.getActivity()); + + Activity activity = binding.getActivity(); + if (!(activity instanceof AgoraPIPActivityProxy)) { + return; } + + if (pipController != null) { + pipController.dispose(); + } + + pipController = new AgoraPIPController( + (AgoraPIPActivityProxy) activity, + new AgoraPIPController.PIPStateChangedListener() { + @Override + public void onPIPStateChangedListener( + AgoraPIPController.PIPState state) { + // put state into a json object + channel.invokeMethod("pipStateChanged", + new HashMap() { + { + put("state", state.getValue()); + } + }); + } + }); } } - private void handlePipMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && pipController != null) { + private void handlePipMethodCall(@NonNull MethodCall call, + @NonNull MethodChannel.Result result) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + pipController == null) { + result.error("IllegalStateException", "PiP is not supported", + "Picture-in-Picture mode is not available on this device (requires Android 8.0 or higher) or the main activity does not implement AgoraPIPActivityProxy"); + return; + } + + try { switch (call.method) { case "pipIsSupported": result.success(pipController.isSupported()); @@ -138,8 +166,10 @@ private void handlePipMethodCall(@NonNull MethodCall call, @NonNull MethodChanne case "pipSetup": final Map args = (Map) call.arguments; Rational aspectRatio = null; - if (args.get("aspectRatioX") != null && args.get("aspectRatioY") != null) { - aspectRatio = new Rational((int) args.get("aspectRatioX"), (int) args.get("aspectRatioY")); + if (args.get("aspectRatioX") != null && + args.get("aspectRatioY") != null) { + aspectRatio = new Rational((int) args.get("aspectRatioX"), + (int) args.get("aspectRatioY")); } Boolean autoEnterEnabled = null; if (args.get("autoEnterEnabled") != null) { @@ -150,25 +180,32 @@ private void handlePipMethodCall(@NonNull MethodCall call, @NonNull MethodChanne args.get("sourceRectHintTop") != null && args.get("sourceRectHintRight") != null && args.get("sourceRectHintBottom") != null) { - sourceRectHint = new Rect((int) args.get("sourceRectHintLeft"), - (int) args.get("sourceRectHintTop"), - (int) args.get("sourceRectHintRight"), - (int) args.get("sourceRectHintBottom")); + sourceRectHint = + new Rect((int) args.get("sourceRectHintLeft"), + (int) args.get("sourceRectHintTop"), + (int) args.get("sourceRectHintRight"), + (int) args.get("sourceRectHintBottom")); } Boolean seamlessResizeEnabled = null; if (args.get("seamlessResizeEnabled") != null) { - seamlessResizeEnabled = (boolean) args.get("seamlessResizeEnabled"); + seamlessResizeEnabled = + (boolean) args.get("seamlessResizeEnabled"); } Boolean useExternalStateMonitor = null; if (args.get("useExternalStateMonitor") != null) { - useExternalStateMonitor = (boolean) args.get("useExternalStateMonitor"); + useExternalStateMonitor = + (boolean) args.get("useExternalStateMonitor"); } Integer externalStateMonitorInterval = null; if (args.get("externalStateMonitorInterval") != null) { - externalStateMonitorInterval = (int) args.get("externalStateMonitorInterval"); + externalStateMonitorInterval = + (int) args.get("externalStateMonitorInterval"); } - result.success(pipController.setup(aspectRatio, autoEnterEnabled, sourceRectHint, seamlessResizeEnabled, useExternalStateMonitor, externalStateMonitorInterval)); + result.success(pipController.setup( + aspectRatio, autoEnterEnabled, sourceRectHint, + seamlessResizeEnabled, useExternalStateMonitor, + externalStateMonitorInterval)); break; case "pipStart": result.success(pipController.start()); @@ -184,8 +221,9 @@ private void handlePipMethodCall(@NonNull MethodCall call, @NonNull MethodChanne default: result.notImplemented(); } - } else { - result.notImplemented(); + } catch (Exception e) { + result.error(e.getClass().getSimpleName(), e.getMessage(), + e.getCause()); } } @@ -206,7 +244,8 @@ public void onDetachedFromActivityForConfigChanges() { } @Override - public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) { + public void onReattachedToActivityForConfigChanges( + @NonNull ActivityPluginBinding binding) { if (videoViewController != null) { videoViewController.onReattachedToActivityForConfigChanges(binding); } diff --git a/docs/integration/Picture-in-Picture.md b/docs/integration/Picture-in-Picture.md new file mode 100644 index 000000000..17a2140b0 --- /dev/null +++ b/docs/integration/Picture-in-Picture.md @@ -0,0 +1,314 @@ +# Picture-in-Picture (PiP) + +## Overview + +The Picture-in-Picture (PiP) feature allows you to display video content in a small floating window while users interact with other parts of your app. You can show local and remote video streams in the PiP window, or display your own custom UI content. This feature is supported on Android and iOS platforms. + +## Features + +- Custom control style for PiP window (iOS only) +- Automatic PiP mode activation when app goes to background +- Customizable PiP window size and aspect ratio +- Dynamic size/aspect ratio adjustment during active PiP mode +- Support for multiple video streams in PiP mode +- Custom content view integration with PiP window +- Flexible layout configuration for multiple video streams in PiP mode + +## Platform Support + +- iOS: 15.0 and above +- Android: 8.0 and above + +## Integration Guide + +### Android Setup + +1. **Declare PiP Support in AndroidManifest.xml** + + > For detailed information, see [Add videos using picture-in-picture (PiP)](https://developer.android.com/develop/ui/views/picture-in-picture#declaring) + + ```xml + According to the [Switch your activity to PiP](https://developer.android.com/develop/ui/views/picture-in-picture#pip_button), the auto enter PiP mode when app go to background is supported on Android 12 and above, so we need to explicitly call `enterPictureInPictureMode()` in `onUserLeaveHint()` to enter PiP mode on earlier Android versions. Which has been done in `AgoraPIPFlutterActivity`, so you don't need to do this in your main activity by yourself. + + ```kotlin + import io.agora.agora_rtc_ng.AgoraPIPFlutterActivity + + class MainActivity: AgoraPIPFlutterActivity() { + ... + } + ``` + + Example: [MainActivity.kt](../../example/android/app/src/main/kotlin/io/agora/agora_rtc_flutter_example/MainActivity.kt#L11) + +### iOS Setup + +1. **Configure Media Playback Capability** + + > For detailed information, see [Configuring your app for media playback](https://developer.apple.com/documentation/avfoundation/configuring-your-app-for-media-playback?language=objc) + + Steps in Xcode: + + 1. Select your app's target and go to Signing & Capabilities tab + 2. Click + Capability button + 3. Add Background Modes capability + 4. Select "Audio, AirPlay, and Picture in Picture" under Background Modes + + Additional Resources: + + - [Background Execution Modes](https://developer.apple.com/documentation/xcode/configuring-background-execution-modes#Specify-the-background-modes-your-app-requires) + - [Adding Capabilities](https://developer.apple.com/documentation/xcode/adding-capabilities-to-your-app#Add-a-capability) + +2. **Camera Access in Multitasking Mode (Optional)** + + > When your app enters a multitasking mode, you should have [com.apple.developer.avfoundation.multitasking-camera-access](https://developer.apple.com/documentation/BundleResources/Entitlements/com.apple.developer.avfoundation.multitasking-camera-access?language=objc) entitlement or set `multitaskingCameraAccessEnabled` to `true` of the capture session. Multitasking modes include Slide Over, Split View, and Picture in Picture (PiP). + + > To learn about best practices for using the camera while multitasking, see [Accessing the camera while multitasking on iPad](https://developer.apple.com/documentation/avkit/accessing-the-camera-while-multitasking-on-ipad?language=objc). + + Requirements: + + - iOS < 16: Requires [com.apple.developer.avfoundation.multitasking-camera-access](https://developer.apple.com/documentation/BundleResources/Entitlements/com.apple.developer.avfoundation.multitasking-camera-access?language=objc) entitlement + - [Contact Apple](https://developer.apple.com/contact/request/multitasking-camera-access/) for permission + - iOS ≥ 16: Set `multitaskingCameraAccessEnabled` to `true` in capture session (coming soon) + +### Flutter Implementation + +> Complete example: [picture_in_picture.dart](../../example/lib/examples/advanced/picture_in_picture/picture_in_picture.dart) + +1. **Basic Setup** + + ```dart + import 'package:agora_rtc_engine/agora_rtc_engine.dart'; + + // Declare controllers + late final RtcEngine _engine; + late final AgoraPipController _pipController; + + // Create and initialize RtcEngine + _engine = createAgoraRtcEngine(); + await _engine.initialize(RtcEngineContext( + // ... configuration + )); + + // iOS-specific render type configuration + if (Platform.isIOS) { + // According to [Adopting Picture in Picture in a Custom Player](https://developer.apple.com/documentation/avkit/adopting_picture_in_picture_in_a_custom_player), + // the PiP window only supports `AVPlayerLayer` or `AVSampleBufferDisplayLayer` for rendering video content. + // Therefore, we need to change the internal render type for iOS to use a compatible layer type. + await _engine.setParameters("{\"che.video.render.mode\":22}"); + } + + // Create PiP controller + _pipController = _engine.createPipController(); + ``` + +2. **Configure PiP State Observer** + + ```dart + _pipController.registerPipStateChangedObserver(AgoraPipStateChangedObserver( + onPipStateChanged: (state, error) { + // Handle state changes + }, + )); + + // Check PiP support + var isPipSupported = await _pipController.pipIsSupported(); + var isPipAutoEnterSupported = await _pipController.pipIsAutoEnterSupported(); + ``` + +3. **Configure PiP Options** + + **Android Configuration:** + + ```dart + AgoraPipOptions options = AgoraPipOptions( + // Setting autoEnterEnabled to true enables seamless transition to PiP mode when the app enters background, + // providing the best user experience recommended by both Android and iOS platforms. + autoEnterEnabled: isPipAutoEnterSupported, + + // Keep the aspect ratio same as the video view. The aspectRatioX and aspectRatioY values + // should match your video dimensions for optimal display. For example, for 1080p video, + // use 16:9 ratio (1920:1080 simplified to 16:9). + aspectRatioX: 16, + aspectRatioY: 9, + + // According to https://developer.android.com/develop/ui/views/picture-in-picture#set-sourcerecthint + // The sourceRectHint defines the initial position and size of the PiP window during the transition animation. + // Setting proper values helps create a smooth animation from your video view to the PiP window. + // If not set correctly, the system may apply a default content overlay, resulting in a jarring transition. + sourceRectHintLeft: 0, + sourceRectHintTop: 0, + sourceRectHintRight: 0, + sourceRectHintBottom: 0, + + // According to https://developer.android.com/develop/ui/views/picture-in-picture#seamless-resizing + // The seamlessResizeEnabled flag enables smooth resizing of the PiP window. + // Set this to true for video content to allow continuous playback during resizing. + // Set this to false for non-video content where seamless resizing isn't needed. + seamlessResizeEnabled: true, + + // The external state monitor checks the PiP view state at the interval specified by externalStateMonitorInterval (100ms). + // This is necessary because FlutterActivity does not forward PiP state change events to the Flutter side. + // Even if your Activity is a subclass of AgoraPIPFlutterActivity, you can still use the external state monitor to track PiP state changes. + useExternalStateMonitor: false, + externalStateMonitorInterval: 100, + ); + ``` + + **iOS Configuration:** + + ```dart + AgoraPipOptions options = AgoraPipOptions( + // Setting autoEnterEnabled to true enables seamless transition to PiP mode when the app enters background, + // providing the best user experience recommended by both Android and iOS platforms. + autoEnterEnabled: isPipAutoEnterSupported, + + // Use preferredContentWidth and preferredContentHeight to set the size of the PIP window. + // These values determine the initial dimensions and can be adjusted while PIP is active. + // For optimal user experience, we recommend matching these dimensions to your video view size. + // The system may adjust the final window size to maintain system constraints. + preferredContentWidth: 1080, + preferredContentHeight: 720, + + // The sourceContentView determines the source frame for the PiP animation and restore target. + // Pass 0 to use the app's root view. For optimal animation, set this to the view containing + // your video content. The system uses this view for the PiP enter/exit animations and as the + // restore target when returning to the app or stopping PiP. + sourceContentView: 0, + + // The contentView determines which view will be displayed in the PIP window. + // If you pass 0, the PIP controller will automatically manage and display all video streams. + // If you pass a specific view ID, you become responsible for managing the content shown in the PIP window. + contentView: 0, // force to use native view + + // The contentViewLayout determines the layout of video streams in the PIP window. + // You can customize the grid layout by specifying: + // - padding: Space between the window edge and content (in pixels) + // - spacing: Space between video streams (in pixels) + // - row: Number of rows in the grid layout + // - column: Number of columns in the grid layout + // + // The SDK provides a basic grid layout system that arranges video streams in a row x column matrix. + // For example: + // - row=2, column=2: Up to 4 video streams in a 2x2 grid + // - row=1, column=2: Up to 2 video streams side by side + // - row=2, column=1: Up to 2 video streams stacked vertically + // + // Note: + // - This layout configuration only takes effect when contentView is 0 (using native view) + // - The grid layout is filled from left-to-right, top-to-bottom + // - Empty cells will be left blank if there are fewer streams than grid spaces + // - For custom layouts beyond the grid system, set contentView to your own view ID + contentViewLayout: AgoraPipContentViewLayout( + padding: 0, + spacing: 2, + row: 2, + column: 2, + ), + + // The videoStreams array specifies which video streams to display in the PIP window. + // Each stream can be configured with properties like uid, sourceType, setupMode, and renderMode. + // Note: + // - This configuration only takes effect when contentView is set to 0 (native view mode). + // - The streams will be laid out according to the contentViewLayout grid configuration. + // - The order of the video streams in the array determines the display order in the PIP window. + // - The SDK will automatically create and manage native views for each video stream. + // - The view property in VideoCanvas will be replaced by the SDK-managed native view. + // - You can customize the rendering of each stream using properties like renderMode and mirrorMode. + videoStreams: [ + AgoraPipVideoStream( + connection: RtcConnection( + channelId: 'channelId', + localUid: 0, + ), + canvas: const VideoCanvas( + uid: 0, + view: 0, // will be replaced by native view + sourceType: VideoSourceType.videoSourceCamera, + setupMode: VideoViewSetupMode.videoViewSetupAdd, + renderMode: RenderModeType.renderModeHidden, + // ... other properties + ), + ), + ..._remoteUsers.entries.map((entry) => AgoraPipVideoStream( + connection: entry.value, + canvas: VideoCanvas( + uid: entry.key, + view: 0, // will be replaced by native view + sourceType: VideoSourceType.videoSourceRemote, + setupMode: VideoViewSetupMode.videoViewSetupAdd, + renderMode: RenderModeType.renderModeHidden), + // ... other properties + )), + ], + + // The controlStyle property determines which controls are visible in the PiP window. + // Available styles: + // * 0: Show all system controls (default) - includes play/pause, forward/backward, close and restore buttons + // * 1: Hide forward and backward buttons - shows only play/pause, close and restore buttons + // * 2: Hide play/pause button and progress bar - shows only close and restore buttons (recommended) + // * 3: Hide all system controls - no buttons visible, including close and restore + // + // Note: For most video conferencing use cases, style 2 is recommended since playback controls + // are not relevant and may confuse users. The close and restore buttons provide essential + // window management functionality. + // Note: We do not handle the event of other controls, so the recommended style is 2 or 3. + controlStyle: 2, + ); + ``` + +4. **PiP Lifecycle Management** + + ```dart + // Setup PiP + await _pipController.pipSetup(options); + + // Start PiP (iOS: Must be user-initiated) + // Important: On iOS, Picture-in-Picture playback must only be initiated in response to explicit user actions (e.g. tapping a button). + // Starting PiP programmatically or automatically may result in App Store rejection. + // For more details, see Apple's guidelines on [Handle User-Initiated Requests](https://developer.apple.com/documentation/avkit/adopting-picture-in-picture-in-a-custom-player?language=objc#Handle-User-Initiated-Requests). + await _pipController.pipStart(); + + // Stop PiP + await _pipController.pipStop(); + + // Cleanup PiP resources + // This will stop PiP mode and dispose any native views created by the PiP controller. + // To use PiP functionality again, you'll need to call pipSetup with new options. + // Note that this method only cleans up PiP-related resources - it does not dispose the controller itself. + await _pipController.pipDispose(); + + // Dispose controller (prevent memory leaks) + // This will stop PiP mode, dispose any native views created by the PiP controller, + // and clean up associated resources. After calling dispose(), this AgoraPipController + // instance becomes invalid and should not be used again. Create a new instance if you + // need to use PiP functionality again. + await _pipController.dispose(); + ``` + +## Important Notes + +1. **iOS User Initiation Requirement** + + > PiP must be initiated by user action on iOS. Programmatic or automatic activation may result in App Store rejection. See [Handle User-Initiated Requests](https://developer.apple.com/documentation/avkit/adopting-picture-in-picture-in-a-custom-player?language=objc#Handle-User-Initiated-Requests) + +2. **Memory Management** + + - Always dispose `AgoraPipController` when no longer needed + - Failure to dispose may result in memory leaks + +3. **Control Styles (iOS)** + - 0: All system controls (default) + - 1: Hide forward/backward buttons + - 2: Hide play/pause and progress bar (recommended for video conferencing) + - 3: Hide all controls diff --git a/example/android/app/src/main/kotlin/io/agora/agora_rtc_flutter_example/MainActivity.kt b/example/android/app/src/main/kotlin/io/agora/agora_rtc_flutter_example/MainActivity.kt index acddf9c6a..5fb9fb1a3 100644 --- a/example/android/app/src/main/kotlin/io/agora/agora_rtc_flutter_example/MainActivity.kt +++ b/example/android/app/src/main/kotlin/io/agora/agora_rtc_flutter_example/MainActivity.kt @@ -1,15 +1,14 @@ package io.agora.agora_rtc_ng_example import android.content.Context -import android.os.Bundle -import android.util.Log import io.agora.agora_rtc_flutter_example.VideoRawDataController -import io.flutter.embedding.android.FlutterActivity + import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel -import io.agora.agora_rtc_ng.AgoraPipActivity -class MainActivity: AgoraPipActivity() { +import io.agora.agora_rtc_ng.AgoraPIPFlutterActivity + +class MainActivity: AgoraPIPFlutterActivity() { private lateinit var methodChannel: MethodChannel private lateinit var sharedNativeHandleMethodChannel: MethodChannel private var videoRawDataController: VideoRawDataController? = null diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index b1662d59d..4a8d65d5b 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -41,7 +41,6 @@ fetch audio processing - voip UIFileSharingEnabled diff --git a/example/lib/examples/advanced/picture_in_picture/picture_in_picture.dart b/example/lib/examples/advanced/picture_in_picture/picture_in_picture.dart index 20bfcaa32..319004ae2 100644 --- a/example/lib/examples/advanced/picture_in_picture/picture_in_picture.dart +++ b/example/lib/examples/advanced/picture_in_picture/picture_in_picture.dart @@ -1,12 +1,14 @@ // ignore_for_file: avoid_print import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import 'package:agora_rtc_engine/agora_rtc_engine.dart'; + import 'package:agora_rtc_engine_example/config/agora.config.dart' as config; -import 'package:agora_rtc_engine_example/components/example_actions_widget.dart'; import 'package:agora_rtc_engine_example/components/log_sink.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; /// PictureInPicture Example class PictureInPicture extends StatefulWidget { @@ -24,33 +26,32 @@ class _State extends State with WidgetsBindingObserver { late TextEditingController _contentWidthController; late TextEditingController _contentHeightController; - int _contextWidth = 0; - int _contextHeight = 0; - bool _isJoined = false; bool _isUseFlutterTexture = false; bool _isInPipMode = false; + bool _isPipDisposed = true; bool? _isPipSupported; bool _isPipAutoEnterSupported = false; AppLifecycleState _lastAppLifecycleState = AppLifecycleState.resumed; - int? _currentPipUid; - int _localViewId = kInvalidViewId; - final Map _remoteViewIds = {}; + double _pipContentRow = 1; + double _pipContentCol = 0; + + final Map _remoteUsers = {}; late final RtcEngine _engine; late final RtcEngineEventHandler _rtcEngineEventHandler; + late final AgoraPipController _pipController; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _channelIdController = TextEditingController(text: config.channelId); - _contentWidthController = TextEditingController(text: '960'); - _contentHeightController = TextEditingController(text: '540'); _initEngine(); + _initContentSizeByPhysicalSize(); } @override @@ -61,7 +62,7 @@ class _State extends State with WidgetsBindingObserver { // Known limitations of Picture-in-Picture (PiP) mode on Android: // // 1. Cannot differentiate between Recent Apps vs Quick Settings Bar triggers: - // Both generate the same window focus change event (false), preventing selective + // Both generate the same window focus change event (false), preventing selective // PiP activation for Recent Apps only. // // 2. Recent Apps interaction: @@ -73,7 +74,7 @@ class _State extends State with WidgetsBindingObserver { // - Most reliable when called during inactive state // - autoEnterEnabled flag alone may not trigger PiP // - Even implementing onUserLeaveHint callback per documentation may not ensure activation - // + // // Based on extensive testing, we recommend using the overlay (floating) window when available, // as it provides optimal user experience. Picture-in-Picture mode should be used as a fallback // option on Android devices where overlay windows are not supported or permitted. @@ -85,12 +86,12 @@ class _State extends State with WidgetsBindingObserver { // it is supported and call pipStart only in the resumed state. if (_lastAppLifecycleState != AppLifecycleState.paused && !_isPipAutoEnterSupported) { - await _engine.pipStart(); + await _pipController.pipStart(); } } else if (state == AppLifecycleState.resumed) { if (!Platform.isAndroid) { // on Android, the pipStop is not supported, the pipStop operation is only bring the activity to background. - await _engine.pipStop(); + await _pipController.pipStop(); } } @@ -133,108 +134,214 @@ class _State extends State with WidgetsBindingObserver { Future _dispose() async { _engine.unregisterEventHandler(_rtcEngineEventHandler); - await _engine.unregisterPipStateChangedObserver(); - await _engine.pipDispose(); + await _pipController.dispose(); await _engine.leaveChannel(); await _engine.release(); } Future _disposePip() async { - await _engine.pipDispose(); + await _pipController.pipDispose(); setState(() { _isInPipMode = false; - _currentPipUid = null; + _isPipDisposed = true; }); } - Future _setupAndStartPip(RtcConnection? connection, int? uid, - int? viewId, Rect? sourceRectHint) async { - if (_currentPipUid != uid) { - // Note: no need to call dispose before setup or re-setup no matter you are using - // Flutter texture or not, the implementation of pip controller in native side will - // handle it. - // await _disposePip(); + Future _initContentSizeByPhysicalSize() async { + // We highly recommend to set the aspect ratio or preferred width and height based on the screen size, + // which will make the PiP experience more seamless. + + // Initialize controllers with default values based on platform + final size = WidgetsBinding.instance.window.physicalSize; + final scale = WidgetsBinding.instance.window.devicePixelRatio; + final width = size.width / scale; + final height = size.height / scale; + + if (Platform.isIOS) { + _contentWidthController = + TextEditingController(text: width.toStringAsFixed(0)); + _contentHeightController = + TextEditingController(text: height.toStringAsFixed(0)); + } else { + // Find the simplest ratio that matches the aspect ratio + int gcd(int a, int b) { + while (b != 0) { + final t = b; + b = a % b; + a = t; + } + return a; + } + + final divisor = gcd(width.toInt(), height.toInt()); + final x = (width / divisor).round(); + final y = (height / divisor).round(); + + _contentWidthController = TextEditingController(text: x.toString()); + _contentHeightController = TextEditingController(text: y.toString()); + } + } - bool isLocal = uid == config.uid; + Future _setupPip() async { + int contentWidth = int.tryParse(_contentWidthController.text) ?? 960; + int contentHeight = int.tryParse(_contentHeightController.text) ?? 540; - AgoraPipOptions options = AgoraPipOptions( - // According to https://developer.android.com/develop/ui/views/picture-in-picture#setautoenterenabled and Apple documentation - // Both platforms recommend setting autoEnterEnabled to true for the best user experience. + AgoraPipOptions options; + if (Platform.isAndroid) { + options = AgoraPipOptions( + // Setting autoEnterEnabled to true enables seamless transition to PiP mode when the app enters background, + // providing the best user experience recommended by both Android and iOS platforms. autoEnterEnabled: _isPipAutoEnterSupported, - // android only + // Keep the aspect ratio same as the video view. The aspectRatioX and aspectRatioY values + // should match your video dimensions for optimal display. For example, for 1080p video, + // use 16:9 ratio (1920:1080 simplified to 16:9). + aspectRatioX: contentWidth, + aspectRatioY: contentHeight, - // Keep the aspect ratio same as the video view. - aspectRatioX: sourceRectHint?.width.toInt() ?? _contextWidth, - aspectRatioY: sourceRectHint?.height.toInt() ?? _contextHeight, // According to https://developer.android.com/develop/ui/views/picture-in-picture#set-sourcerecthint - // If your app doesn't provide a proper sourceRectHint, the system tries to apply a content overlay - // during the PiP entering animation, which makes for a poor user experience. - sourceRectHintLeft: sourceRectHint?.left.toInt() ?? 0, - sourceRectHintTop: sourceRectHint?.top.toInt() ?? 0, - sourceRectHintRight: sourceRectHint?.right.toInt() ?? _contextWidth, - sourceRectHintBottom: sourceRectHint?.bottom.toInt() ?? _contextHeight, + // The sourceRectHint defines the initial position and size of the PiP window during the transition animation. + // Setting proper values helps create a smooth animation from your video view to the PiP window. + // If not set correctly, the system may apply a default content overlay, resulting in a jarring transition. + sourceRectHintLeft: 0, + sourceRectHintTop: 0, + sourceRectHintRight: 0, + sourceRectHintBottom: 0, + // According to https://developer.android.com/develop/ui/views/picture-in-picture#seamless-resizing - // The setSeamlessResizeEnabled flag is set to true by default for backward compatibility. - // Leave this set to true for video content, and change it to false for non-video content. + // The seamlessResizeEnabled flag enables smooth resizing of the PiP window. + // Set this to true for video content to allow continuous playback during resizing. + // Set this to false for non-video content where seamless resizing isn't needed. seamlessResizeEnabled: true, + // The external state monitor checks the PiP view state at the interval specified by externalStateMonitorInterval (100ms). // This is necessary because FlutterActivity does not forward PiP state change events to the Flutter side. - // Even if your Activity is a subclass of AgoraPipActivity, you can still use the external state monitor to track PiP state changes. + // Even if your Activity is a subclass of AgoraPIPFlutterActivity, you can still use the external state monitor to track PiP state changes. useExternalStateMonitor: false, externalStateMonitorInterval: 100, - - // ios only - preferredContentWidth: - int.tryParse(_contentWidthController.text) ?? 960, - preferredContentHeight: - int.tryParse(_contentHeightController.text) ?? 540, - - connection: connection, - videoCanvas: (uid != null && viewId != null) - ? VideoCanvas( - // Must set uid to 0 for the local view, otherwise the video will not be displayed. - uid: isLocal ? 0 : uid, - // The view represents the handle of the video view, which is the - // native view id. Setting it to 0 will use the root view as the - // source view. - view: _isUseFlutterTexture ? 0 : viewId, - mirrorMode: isLocal - ? VideoMirrorModeType.videoMirrorModeEnabled - : VideoMirrorModeType.videoMirrorModeDisabled, - renderMode: RenderModeType.renderModeFit, - sourceType: isLocal - ? VideoSourceType.videoSourceCamera - : VideoSourceType.videoSourceRemote, - // The setupMode is fixed to videoViewSetupAdd in the native side, - // so there is no need to set it explicitly here, and it will be ignored. - // If you don't want to display the video when PiP starts, - // you'll need to hide the video view manually. - // setupMode: VideoViewSetupMode.videoViewSetupAdd, - ) - : null, ); + } else if (Platform.isIOS) { + options = AgoraPipOptions( + // Setting autoEnterEnabled to true enables seamless transition to PiP mode when the app enters background, + // providing the best user experience recommended by both Android and iOS platforms. + autoEnterEnabled: _isPipAutoEnterSupported, - logSink.log('[setupPip] options: ${options.toDictionary()}'); + // Use preferredContentWidth and preferredContentHeight to set the size of the PIP window. + // These values determine the initial dimensions and can be adjusted while PIP is active. + // For optimal user experience, we recommend matching these dimensions to your video view size. + // The system may adjust the final window size to maintain system constraints. + preferredContentWidth: contentWidth, + preferredContentHeight: contentHeight, + + // The sourceContentView determines the source frame for the PiP animation and restore target. + // Pass 0 to use the app's root view. For optimal animation, set this to the view containing + // your video content. The system uses this view for the PiP enter/exit animations and as the + // restore target when returning to the app or stopping PiP. + sourceContentView: 0, + + // The contentView determines which view will be displayed in the PIP window. + // If you pass 0, the PIP controller will automatically manage and display all video streams. + // If you pass a specific view ID, you become responsible for managing the content shown in the PIP window. + contentView: 0, // force to use native view + + // The contentViewLayout determines the layout of video streams in the PIP window. + // You can customize the grid layout by specifying: + // - padding: Space between the window edge and content (in pixels) + // - spacing: Space between video streams (in pixels) + // - row: Number of rows in the grid layout + // - column: Number of columns in the grid layout + // + // The SDK provides a basic grid layout system that arranges video streams in a row x column matrix. + // For example: + // - row=2, column=2: Up to 4 video streams in a 2x2 grid + // - row=1, column=2: Up to 2 video streams side by side + // - row=2, column=1: Up to 2 video streams stacked vertically + // + // Note: + // - This layout configuration only takes effect when contentView is 0 (using native view) + // - The grid layout is filled from left-to-right, top-to-bottom + // - Empty cells will be left blank if there are fewer streams than grid spaces + // - For custom layouts beyond the grid system, set contentView to your own view ID + contentViewLayout: AgoraPipContentViewLayout( + padding: 0, + spacing: 2, + row: _pipContentRow.toInt(), + column: _pipContentCol.toInt(), + ), - await _engine.pipSetup(options); + // The videoStreams array specifies which video streams to display in the PIP window. + // Each stream can be configured with properties like uid, sourceType, setupMode, and renderMode. + // Note: + // - This configuration only takes effect when contentView is set to 0 (native view mode). + // - The streams will be laid out according to the contentViewLayout grid configuration. + // - The order of the video streams in the array determines the display order in the PIP window. + // - The SDK will automatically create and manage native views for each video stream. + // - The view property in VideoCanvas will be replaced by the SDK-managed native view. + // - You can customize the rendering of each stream using properties like renderMode and mirrorMode. + videoStreams: [ + AgoraPipVideoStream( + connection: RtcConnection( + channelId: _channelIdController.text, + localUid: config.uid, + ), + canvas: const VideoCanvas( + uid: 0, + sourceType: VideoSourceType.videoSourceCamera, + setupMode: VideoViewSetupMode.videoViewSetupAdd, + renderMode: RenderModeType.renderModeHidden, + ), + ), + ..._remoteUsers.entries.map((entry) => AgoraPipVideoStream( + connection: entry.value, + canvas: VideoCanvas( + uid: entry.key, + sourceType: VideoSourceType.videoSourceRemote, + setupMode: VideoViewSetupMode.videoViewSetupAdd, + renderMode: RenderModeType.renderModeHidden), + )), + ], + + // The controlStyle property determines which controls are visible in the PiP window. + // Available styles: + // * 0: Show all system controls (default) - includes play/pause, forward/backward, close and restore buttons + // * 1: Hide forward and backward buttons - shows only play/pause, close and restore buttons + // * 2: Hide play/pause button and progress bar - shows only close and restore buttons (recommended) + // * 3: Hide all system controls - no buttons visible, including close and restore + // + // Note: For most video conferencing use cases, style 2 is recommended since playback controls + // are not relevant and may confuse users. The close and restore buttons provide essential + // window management functionality. + // Note: We do not handle the event of other controls, so the recommended style is 2 or 3. + controlStyle: 2, // only show close and restore button + ); + } else { + logSink.log('[setupPip] not supported on this platform'); + return; + } - _currentPipUid = uid; + logSink.log('[setupPip] options: ${options.toDictionary()}'); - setState(() {}); + final result = await _pipController.pipSetup(options); + if (result) { + setState(() { + _isPipDisposed = false; + }); } - - // Note: always call pipStart even the autoEnterEnabled is true. - await _engine.pipStart(); } Future _initEngine() async { _engine = createAgoraRtcEngine(); - + _pipController = _engine.createPipController(); await _engine.initialize(RtcEngineContext( appId: config.appId, )); + + if (Platform.isIOS) { + // to render video in pip window with sdk, must call this to set the render type + await _engine.setParameters("{\"che.video.render.mode\":22}"); + } + _rtcEngineEventHandler = RtcEngineEventHandler( onError: (ErrorCodeType err, String msg) { logSink.log('[onError] err: $err, msg: $msg'); @@ -247,31 +354,33 @@ class _State extends State with WidgetsBindingObserver { }, onUserJoined: (RtcConnection connection, int rUid, int elapsed) async { logSink.log( '[onUserJoined] connection: ${connection.toJson()} remoteUid: $rUid elapsed: $elapsed'); - setState(() { - // default view id is invalid - _remoteViewIds[rUid] = kInvalidViewId; - }); + _remoteUsers[rUid] = connection; + + if (!_isPipDisposed) { + _setupPip(); + } + setState(() {}); }, onUserOffline: (RtcConnection connection, int rUid, UserOfflineReasonType reason) { logSink.log( '[onUserOffline] connection: ${connection.toJson()} rUid: $rUid reason: $reason'); + _remoteUsers.remove(rUid); - _remoteViewIds.remove(rUid); - if (_isInPipMode && _currentPipUid == rUid) { - _disposePip(); + if (!_isPipDisposed) { + _setupPip(); } + setState(() {}); }, onLeaveChannel: (RtcConnection connection, RtcStats stats) { logSink.log( '[onLeaveChannel] connection: ${connection.toJson()} stats: ${stats.toJson()}'); + _remoteUsers.clear(); + if (!_isPipDisposed) { + _setupPip(); + } + setState(() { _isJoined = false; - _remoteViewIds.clear(); - - // only dispose the pip controller when current pip uid is not the local uid - if (_isInPipMode && _currentPipUid != config.uid) { - _disposePip(); - } }); }, onRemoteVideoStateChanged: (RtcConnection connection, int remoteUid, @@ -282,7 +391,7 @@ class _State extends State with WidgetsBindingObserver { '[onRemoteVideoStateChanged] connection: ${connection.toJson()} remoteUid: $remoteUid state: $state reason: $reason elapsed: $elapsed'); }); - _engine.registerPipStateChangedObserver(AgoraPipStateChangedObserver( + _pipController.registerPipStateChangedObserver(AgoraPipStateChangedObserver( onPipStateChanged: (state, error) { logSink.log('[onPipStateChanged] state: $state, error: $error'); setState(() { @@ -303,8 +412,9 @@ class _State extends State with WidgetsBindingObserver { await _engine.enableVideo(); await _engine.startPreview(); - var isPipSupported = await _engine.pipIsSupported(); - var isPipAutoEnterSupported = await _engine.pipIsAutoEnterSupported(); + var isPipSupported = await _pipController.pipIsSupported(); + var isPipAutoEnterSupported = + await _pipController.pipIsAutoEnterSupported(); setState(() { _isPipSupported = isPipSupported; @@ -331,7 +441,6 @@ class _State extends State with WidgetsBindingObserver { required bool isLocal, int? remoteUid, RtcConnection? connection, - int? viewId, double? width, double? height, }) { @@ -346,11 +455,10 @@ class _State extends State with WidgetsBindingObserver { ? VideoViewController( rtcEngine: _engine, useFlutterTexture: _isUseFlutterTexture, - canvas: VideoCanvas( + canvas: const VideoCanvas( uid: 0, - setupMode: _isUseFlutterTexture - ? VideoViewSetupMode.videoViewSetupAdd - : null, + setupMode: VideoViewSetupMode.videoViewSetupAdd, + sourceType: VideoSourceType.videoSourceCamera, ), ) : VideoViewController.remote( @@ -358,48 +466,17 @@ class _State extends State with WidgetsBindingObserver { useFlutterTexture: _isUseFlutterTexture, canvas: VideoCanvas( uid: remoteUid, - setupMode: _isUseFlutterTexture - ? VideoViewSetupMode.videoViewSetupAdd - : null, + setupMode: VideoViewSetupMode.videoViewSetupAdd, + sourceType: VideoSourceType.videoSourceRemote, ), connection: connection!, ), onAgoraVideoViewCreated: (createdViewId) { if (isLocal) { - _localViewId = createdViewId; _engine.startPreview(); - } else { - _remoteViewIds[remoteUid!] = createdViewId; } }, ), - // No additional overlay needed for Android - // For iOS, we need to add an overlay on the original video area - // Ideally we should destroy the VideoViewController, but this is not currently supported - // Testing shows that a PiP view has minimal performance impact, especially when app is in background - // So we temporarily use an overlay to indicate this video is in PiP mode - if (!Platform.isAndroid && - _isInPipMode && - (isLocal ? 0 : remoteUid) == _currentPipUid) - Container( - color: Colors.black, - ), - if (!(_isInPipMode)) - Positioned( - right: 0, - bottom: 0, - child: ElevatedButton( - onPressed: () { - _setupAndStartPip( - connection, - isLocal ? 0 : remoteUid, - isLocal ? _localViewId : _remoteViewIds[remoteUid], - context.findRenderObject()?.paintBounds, - ); - }, - child: const Text('Enter PIP'), - ), - ), ], ), ), @@ -415,16 +492,13 @@ class _State extends State with WidgetsBindingObserver { child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - children: List.of(_remoteViewIds.entries.map( + children: List.of(_remoteUsers.entries.map( (entry) => _videoViewCard( isLocal: false, remoteUid: entry.key, - connection: RtcConnection( - channelId: _channelIdController.text, - localUid: config.uid, - ), - width: 100, - height: 100, + connection: entry.value, + width: 200, + height: 200, ), )), ), @@ -436,9 +510,6 @@ class _State extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { - _contextWidth = MediaQuery.of(context).size.width.toInt(); - _contextHeight = MediaQuery.of(context).size.height.toInt(); - if (_isPipSupported == null) { return Container(); } @@ -458,122 +529,203 @@ class _State extends State with WidgetsBindingObserver { return _videoViewStack(); } - return ExampleActionsWidget( - displayContentBuilder: (context, isLayoutHorizontal) { - return Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - children: [ - Expanded(child: _videoViewStack()), - const SizedBox( - height: 50, - ) - ], - ); - }, - actionsBuilder: (context, isLayoutHorizontal) { - return Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + return Scaffold( + body: SafeArea( + child: Stack( children: [ - if (!kIsWeb && - (defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS)) - Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Rendered by Flutter texture: '), - Switch( - value: _isUseFlutterTexture, - onChanged: _isJoined - ? null - : (changed) { + // Video as background + Positioned.fill( + child: _videoViewStack(), + ), + + // Controls at the bottom + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.all(8), + color: Colors.black.withOpacity(0.5), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // First row: Channel ID + Flutter texture switch + Join/Leave button + Row( + children: [ + Expanded( + flex: 3, + child: TextField( + controller: _channelIdController, + decoration: const InputDecoration( + hintText: 'Channel ID', + fillColor: Colors.white, + filled: true, + border: OutlineInputBorder(), + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + ), + ), + ), + if (!kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == + TargetPlatform.iOS)) ...[ + const SizedBox(width: 4), + // Flutter texture switch + const Text( + 'Texture:', + style: TextStyle(color: Colors.white), + ), + Switch( + value: _isUseFlutterTexture, + onChanged: _isJoined + ? null + : (changed) { + setState(() { + _isUseFlutterTexture = changed; + }); + }, + ), + ], + const SizedBox(width: 4), + ElevatedButton( + onPressed: _isJoined ? _leaveChannel : _joinChannel, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + ), + child: Text( + _isJoined ? 'Leave Channel' : 'Join Channel'), + ), + ], + ), + // Second row: Content size and layout + const SizedBox(height: 4), + Row( + children: [ + // Content Width + Expanded( + child: TextField( + controller: _contentWidthController, + decoration: const InputDecoration( + hintText: 'Width', + border: OutlineInputBorder(), + isDense: true, + fillColor: Colors.white, + filled: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + ), + ), + ), + const SizedBox(width: 4), + // Content Height + Expanded( + child: TextField( + controller: _contentHeightController, + decoration: const InputDecoration( + hintText: 'Height', + border: OutlineInputBorder(), + isDense: true, + fillColor: Colors.white, + filled: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + ), + ), + ), + if (Platform.isIOS) ...[ + const SizedBox(width: 4), + ] + ], + ), + // Third row: Row and Column + if (Platform.isIOS) ...[ + const SizedBox(width: 4), + Row( + children: [ + Text( + 'Row: ${_pipContentRow.toInt()}', + style: const TextStyle(color: Colors.white), + ), + Expanded( + child: Slider( + value: _pipContentRow, + min: 0, + max: 10, + divisions: 10, + label: _pipContentRow.toInt().toString(), + onChanged: (value) { setState(() { - _isUseFlutterTexture = changed; + _pipContentRow = value; }); }, - ) - ], - ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('contentWidth: '), - TextField( - controller: _contentWidthController, - decoration: const InputDecoration( - hintText: 'contentWidth', - border: OutlineInputBorder(gapPadding: 0.0), - isDense: true, - ), + ), + ), + Text( + 'Column: ${_pipContentCol.toInt()}', + style: const TextStyle(color: Colors.white), + ), + Expanded( + child: Slider( + value: _pipContentCol, + min: 0, + max: 10, + divisions: 10, + label: _pipContentCol.toInt().toString(), + onChanged: (value) { + setState(() { + _pipContentCol = value; + }); + }, + ), + ), + ], ), ], - ), - ), - const SizedBox( - width: 10, - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('contentHeigth: '), - TextField( - controller: _contentHeightController, - decoration: const InputDecoration( - hintText: 'contentHeigth', - border: OutlineInputBorder(), - isDense: true, + // Fourth row: Setup PIP + Start PIP + Stop PIP + Dispose PIP + const SizedBox(width: 4), + Row( + children: [ + ElevatedButton( + onPressed: _setupPip, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + ), + child: const Text('Setup PIP'), ), - ), - ], - ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _isInPipMode + ? _pipController.pipStop + : _pipController.pipStart, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + ), + child: Text(_isInPipMode ? 'Stop PIP' : 'Start PIP'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _disposePip, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + ), + child: const Text('Dispose PIP'), + ), + ], + ), + ], ), - ], - ), - const SizedBox( - height: 20, - ), - TextField( - controller: _channelIdController, - decoration: const InputDecoration(hintText: 'Channel ID'), - ), - const SizedBox( - height: 20, - ), - Row( - children: [ - Expanded( - flex: 1, - child: ElevatedButton( - onPressed: _isJoined ? _leaveChannel : _joinChannel, - child: Text('${_isJoined ? 'Leave' : 'Join'} channel'), - ), - ) - ], + ), ), ], - ); - }, + ), + ), ); } } diff --git a/ios/.gitignore b/ios/.gitignore index 6ea139b2e..f9203cb6b 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -41,3 +41,4 @@ AgoraRtcWrapper.xcframework libs/ AgoraRtcWrapper.podspec zip_download_path/ +AgoraPIPKit \ No newline at end of file diff --git a/ios/Classes/AgoraPipController.h b/ios/Classes/AgoraPipController.h deleted file mode 100644 index 7374c72f3..000000000 --- a/ios/Classes/AgoraPipController.h +++ /dev/null @@ -1,159 +0,0 @@ -#ifndef AGORA_IOS_CLASSES_AGORAPIPCONTROLLER_H_ -#define AGORA_IOS_CLASSES_AGORAPIPCONTROLLER_H_ - -#import -#import -#import - -#import - -/** - * AgoraPipState - * @note AgoraPipStateStarted: pip is started - * @note AgoraPipStateStopped: pip is stopped - * @note AgoraPipStateFailed: pip is failed - */ -typedef NS_ENUM(NSInteger, AgoraPipState) { - AgoraPipStateStarted = 0, - AgoraPipStateStopped = 1, - AgoraPipStateFailed = 2, -}; - -/** - * @protocol AgoraPipStateChangedDelegate - * @abstract A protocol that defines the methods for pip state changed. - */ -@protocol AgoraPipStateChangedDelegate - -/** - * @method pipStateChanged - * @param state - * The state of pip. - * @param error - * The error message. - * @abstract Delegate can implement this method to handle the pip state changed. - */ -- (void)pipStateChanged:(AgoraPipState)state error:(NSString * _Nullable)error; - -@end - -/** - * @class AgoraPipOptions - * @abstract A class that defines the options for pip. - */ -@interface AgoraPipOptions : NSObject - -/** - * @property autoEnterEnabled - * @abstract Whether to enable auto enter pip. - */ -@property(nonatomic, assign) BOOL autoEnterEnabled; - -/** - * @property preferredContentSize - * @abstract The preferred content size for pip. - */ -@property(nonatomic, assign) CGSize preferredContentSize; - -@property(nonatomic, strong) AgoraRtcConnection* _Nullable connection; - -/** - * @property videoCanvas - * @abstract The video canvas for pip. - */ -@property(nonatomic, strong) AgoraRtcVideoCanvas * _Nullable videoCanvas; - -/** - * @property irisRtcRendering - * @abstract The internal rendering pointer value. - */ -@property(nonatomic, assign) uintptr_t renderingHandle; - -@end - -/** - * @class AgoraPipController - * @abstract A class that controls the pip. - */ -@interface AgoraPipController : NSObject - -/** - * @method initWith - * @param delegate - * The delegate of pip state changed. - * @abstract Initialize the pip controller. - */ -- (instancetype _Nonnull)initWith:(id _Nonnull)delegate; - -/** - * @method isSupported - * @abstract Check if pip is supported. - * @return Whether pip is supported. - * @discussion This method is used to check if pip is supported, When No all - * other methods will return NO or do nothing. - */ -- (BOOL)isSupported; - -/** - * @method isAutoEnterSupported - * @abstract Check if pip is auto enter supported. - * @return Whether pip is auto enter supported. - */ -- (BOOL)isAutoEnterSupported; - -/** - * @method isActivated - * @abstract Check if pip is activated. - * @return Whether pip is activated. - */ -- (BOOL)isActivated; - -/** - * @method setup - * @param options - * The options for pip. - * @abstract Setup pip or update pip options. - * @return Whether pip is setup successfully. - * @discussion This method is used to setup pip or update pip options, but only - * the `videoCanvas` is allowed to update after the pip controller is - * initialized, unless you call the `dispose` method and re-initialize the pip - * controller. - */ -- (BOOL)setup:(AgoraPipOptions * _Nonnull)options; - -/** - * @method start - * @abstract Start pip. - * @return Whether start pip is successful or not. - * @discussion This method is used to start pip, however, it will only works - * when application is in the foreground. If you want to start pip when - * application is changing to the background, you should set the - * `autoEnterEnabled` to YES when calling the `setup` method. - */ -- (BOOL)start; - -/** - * @method stop - * @abstract Stop pip. - * @discussion This method is used to stop pip, however, it will only works when - * application is in the foreground. If you want to stop pip in the background, - * you can use the `dispose` method, which will destroy the internal pip - * controller and release the pip view. - * If `isPictureInPictureActive` is NO, this method will do nothing. - */ -- (void)stop; - -/** - * @method dispose - * @abstract Dispose all resources that pip controller holds. - * @discussion This method is used to dispose all resources that pip controller - * holds, which will destroy the internal pip controller and release the pip - * view. Accroding to the Apple's documentation, you should call this method - * when you want to stop pip in the background. see: - * https://developer.apple.com/documentation/avkit/adopting-picture-in-picture-for-video-calls?language=objc - */ -- (void)dispose; - -@end - -#endif /* AGORA_IOS_CLASSES_AGORAPIPCONTROLLER_H_ */ diff --git a/ios/Classes/AgoraPipController.mm b/ios/Classes/AgoraPipController.mm deleted file mode 100644 index ae630b004..000000000 --- a/ios/Classes/AgoraPipController.mm +++ /dev/null @@ -1,600 +0,0 @@ -#include "AgoraPipController.h" - -#import -#import -#include - -#import -#import - -#ifdef DEBUG -#define ENABLE_LOG 1 -#else -#define ENABLE_LOG 0 -#endif - -#if ENABLE_LOG -#define AGORA_PIP_LOG(fmt, ...) NSLog((@"[AgoraPIP] " fmt), ##__VA_ARGS__) -#else -#define AGORA_PIP_LOG(fmt, ...) -#endif - -struct AgoraPipFrameMeta { - int rotation_ = 0; - AgoraVideoMirrorMode mirrorMode_ = - AgoraVideoMirrorMode::AgoraVideoMirrorModeAuto; - AgoraVideoRenderMode renderMode_ = - AgoraVideoRenderMode::AgoraVideoRenderModeFit; - - bool operator==(const AgoraPipFrameMeta &other) const { - return mirrorMode_ == other.mirrorMode_ && rotation_ == other.rotation_; - } - - bool operator!=(const AgoraPipFrameMeta &other) const { - return !(*this == other); - } - - CGAffineTransform getTransform() const { - CGAffineTransform matrix = CGAffineTransformIdentity; - switch (rotation_) { - case 90: - matrix = CGAffineTransformRotate(matrix, M_PI_2); - break; - case 180: - matrix = CGAffineTransformRotate(matrix, M_PI); - break; - case 270: - matrix = CGAffineTransformRotate(matrix, -M_PI_2); - break; - default: - break; - } - if (mirrorMode_ == AgoraVideoMirrorMode::AgoraVideoMirrorModeEnabled) { - matrix = CGAffineTransformScale(matrix, -1, 1); - } - return matrix; - } -}; - -@interface AgoraPipView : UIView - -@property(nonatomic, assign) AgoraPipFrameMeta frameMeta; - -/// Tracks the latest pixel buffer sent from AVFoundation's sample buffer -/// delegate callback. Used to deliver the latest pixel buffer to the flutter -/// engine via the `copyPixelBuffer` API. -@property(readwrite, nonatomic) CVPixelBufferRef latestPixelBuffer; - -/// The queue on which `latestPixelBuffer` property is accessed. -@property(strong, nonatomic) dispatch_queue_t pixelBufferSynchronizationQueue; - -@end - -@implementation AgoraPipView - -- (instancetype)init { - self = [super init]; - if (self) { - _pixelBufferSynchronizationQueue = dispatch_queue_create( - "com.agora.pip.pixelBufferSynchronizationQueue", nil); - } - return self; -} - -+ (Class)layerClass { - return [AVSampleBufferDisplayLayer class]; -} - -- (AVSampleBufferDisplayLayer *)sampleBufferDisplayLayer { - return (AVSampleBufferDisplayLayer *)self.layer; -} - -- (void)setMirrorMode:(enum AgoraVideoMirrorMode)mirrorMode { - if (_frameMeta.mirrorMode_ == mirrorMode) { - return; - } - - _frameMeta.mirrorMode_ = mirrorMode; - - [[self sampleBufferDisplayLayer] - setAffineTransform:_frameMeta.getTransform()]; -} - -- (void)setRenderMode:(enum AgoraVideoRenderMode)renderMode { - if (_frameMeta.renderMode_ == renderMode) { - return; - } - - _frameMeta.renderMode_ = renderMode; - - if (_frameMeta.renderMode_ == AgoraVideoRenderMode::AgoraVideoRenderModeFit) { - [[self sampleBufferDisplayLayer] - setVideoGravity:AVLayerVideoGravityResizeAspect]; - } else { - [[self sampleBufferDisplayLayer] - setVideoGravity:AVLayerVideoGravityResizeAspectFill]; - } -} - -- (void)renderCVPixelBuffer { - __block CVPixelBufferRef pixelBuffer = nil; - dispatch_sync(self.pixelBufferSynchronizationQueue, ^{ - pixelBuffer = self.latestPixelBuffer; - self.latestPixelBuffer = nil; - }); - - if (!pixelBuffer) { - return; - } - - CMVideoFormatDescriptionRef videoInfo = nullptr; - CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault, pixelBuffer, - &videoInfo); - - CMSampleTimingInfo timingInfo; - timingInfo.duration = kCMTimeZero; - timingInfo.decodeTimeStamp = kCMTimeInvalid; - timingInfo.presentationTimeStamp = - CMTimeMake(int64_t(CACurrentMediaTime() * 1000), 1000); - - CMSampleBufferRef sampleBuffer = nullptr; - CMSampleBufferCreateReadyWithImageBuffer( - kCFAllocatorDefault, pixelBuffer, videoInfo, &timingInfo, &sampleBuffer); - - // when switch to background too fast, the sampleBufferDisplayLayer status - // will be failed, we need to flush it. - if ([[self sampleBufferDisplayLayer] status] == - AVQueuedSampleBufferRenderingStatusFailed) { - AGORA_PIP_LOG(@"sampleBufferDisplayLayer status failed, current error: %@", - [[self sampleBufferDisplayLayer] error]); - [[self sampleBufferDisplayLayer] flush]; - } - - [[self sampleBufferDisplayLayer] enqueueSampleBuffer:sampleBuffer]; - if (sampleBuffer) { - CFRelease(sampleBuffer); - } - if (videoInfo) { - CFRelease(videoInfo); - } - CFRelease(pixelBuffer); -} - -- (void)renderFrame:(const agora::media::base::VideoFrame *_Nonnull)frame { - CVPixelBufferRef _Nullable pixelBuffer = - reinterpret_cast(frame->pixelBuffer); - if (!pixelBuffer) { - return; - } - - __block CVPixelBufferRef previousPixelBuffer = nil; - dispatch_sync(self.pixelBufferSynchronizationQueue, ^{ - previousPixelBuffer = self.latestPixelBuffer; - self.latestPixelBuffer = CVPixelBufferRetain(pixelBuffer); - }); - if (previousPixelBuffer) { - CFRelease(previousPixelBuffer); - } - - int rotation = frame->rotation; - - __weak typeof(self) weakSelf = self; - dispatch_async(dispatch_get_main_queue(), ^{ - __strong typeof(weakSelf) strongSelf = weakSelf; - if (!strongSelf) { - AGORA_PIP_LOG(@"reference of AgoraPipView is nil, skip rendering"); - return; - } - - AgoraPipFrameMeta frameMeta{ - .rotation_ = rotation, - .mirrorMode_ = strongSelf->_frameMeta.mirrorMode_, - .renderMode_ = strongSelf->_frameMeta.renderMode_}; - if (frameMeta != strongSelf->_frameMeta) { - strongSelf->_frameMeta = frameMeta; - [[strongSelf sampleBufferDisplayLayer] - setAffineTransform:strongSelf->_frameMeta.getTransform()]; - } - - // notify new frame available on main thread - [strongSelf renderCVPixelBuffer]; - }); -} - -- (void)dealloc { - dispatch_sync(self.pixelBufferSynchronizationQueue, ^{ - if (self.latestPixelBuffer) { - CFRelease(self.latestPixelBuffer); - self.latestPixelBuffer = nil; - } - }); -} - -@end - -@implementation AgoraPipOptions { -} -@end - -class AgoraVideoFrameDelegate : public agora::iris::VideoFrameObserverDelegate { -public: - AgoraVideoFrameDelegate(AgoraPipView *view, - agora::iris::IrisRtcRendering *irisRendering, - const IrisRtcVideoFrameConfig &config) - : view_(view), irisRendering_(irisRendering) { - AGORA_PIP_LOG(@"AgoraVideoFrameDelegate constructor"); - memcpy(&config_, &config, sizeof(IrisRtcVideoFrameConfig)); - videoFrameDelegateId_ = - irisRendering->AddVideoFrameObserverDelegate(config_, this); - } - - ~AgoraVideoFrameDelegate() { - AGORA_PIP_LOG(@"AgoraVideoFrameDelegate destructor"); - if (videoFrameDelegateId_ >= 0) { - irisRendering_->RemoveVideoFrameObserverDelegate(videoFrameDelegateId_); - } - } - - bool IsVideoFrameConfigEqual(const IrisRtcVideoFrameConfig &config) { - return config_.video_source_type == config.video_source_type && - config_.video_frame_format == config.video_frame_format && - config_.video_view_setup_mode == config.video_view_setup_mode && - config_.observed_frame_position == config.observed_frame_position && - config_.uid == config.uid && - strcmp(config_.channelId, config.channelId) == 0; - } - - void OnVideoFrameReceived(const void *videoFrame, - const IrisRtcVideoFrameConfig &config, - bool resize) override { - auto *frame = - static_cast(videoFrame); - - if (frame->width == 0 || frame->height == 0) { - return; - } - - // Using dispatch_sync here would cause a deadlock: - // 1. The renderer thread would be blocked waiting for the main thread - // 2. When dispose() is called on the main thread, it needs to acquire the - // renderer lock - // to destroy the video frame delegate, but can't because the renderer is - // blocked - // - // We can safely call view_ methods directly since AgoraPipController - // guarantees that AgoraPipView's lifetime exceeds AgoraVideoFrameDelegate's - // lifetime. The view handles its own dispatch queue internally. - // dispatch_async(dispatch_get_main_queue(), ^{ - [view_ renderFrame:frame]; - // }); - } - -private: - AgoraPipView *view_; - agora::iris::IrisRtcRendering *irisRendering_; - - int videoFrameDelegateId_; - IrisRtcVideoFrameConfig config_; -}; - -@interface AgoraPipController () - -// delegate -@property(nonatomic, weak) id pipStateDelegate; - -// is activated -@property(atomic, assign) BOOL isPipActivated; - -// pip view -@property(nonatomic, strong) AgoraPipView *pipView; - -// pip controller -@property(nonatomic, strong) AVPictureInPictureController *pipController; - -// video frame delegate -@property(nonatomic, assign) AgoraVideoFrameDelegate *videoFrameDelegate; - -@end - -@implementation AgoraPipController - -- (instancetype)initWith:(id)delegate { - self = [super init]; - if (self) { - _pipStateDelegate = delegate; - } - return self; -} - -- (BOOL)isSupported { - // In iOS 15 and later, AVKit provides PiP support for video-calling apps, - // which enables you to deliver a familiar video-calling experience that - // behaves like FaceTime. - // https://developer.apple.com/documentation/avkit/avpictureinpicturecontroller/ispictureinpicturesupported()?language=objc - // https://developer.apple.com/documentation/avkit/adopting-picture-in-picture-for-video-calls?language=objc - // - if (__builtin_available(iOS 15.0, *)) { - return [AVPictureInPictureController isPictureInPictureSupported]; - } - - return NO; -} - -- (BOOL)isAutoEnterSupported { - // canStartPictureInPictureAutomaticallyFromInline is only available on iOS - // after 14.2, so we just need to check if pip is supported. - // https://developer.apple.com/documentation/avkit/avpictureinpicturecontroller/canstartpictureinpictureautomaticallyfrominline?language=objc - // - return [self isSupported]; -} - -- (BOOL)isActivated { - return _isPipActivated; -} - -- (BOOL)setup:(AgoraPipOptions *)options { - AGORA_PIP_LOG(@"AgoraPipController setup with preferredContentSize: %@, " - @"autoEnterEnabled: %d, mirrorMode: %lu, renderMode: %lu, " - @"renderingHandle: %p, uid: %lu", - NSStringFromCGSize(options.preferredContentSize), - options.autoEnterEnabled, - static_cast(options.videoCanvas.mirrorMode), - static_cast(options.videoCanvas.renderMode), - reinterpret_cast(options.renderingHandle), - static_cast(options.videoCanvas.uid)); - if (![self isSupported]) { - [_pipStateDelegate pipStateChanged:AgoraPipStateFailed - error:@"Pip is not supported"]; - return NO; - } - - if (__builtin_available(iOS 15.0, *)) { - // we allow the videoCanvas to be nil, which means to use the root view - // of the app as the source view and do not render the video for now. - UIView *currentVideoSourceView = - (options.videoCanvas != nil && options.videoCanvas.view != nil) - ? options.videoCanvas.view - : [UIApplication.sharedApplication.keyWindow rootViewController] - .view; - - // We need to setup or re-setup the pip controller if: - // 1. The pip controller hasn't been initialized yet (_pipController == nil) - // 2. The content source is missing (_pipController.contentSource == nil) - // 3. The active video call source view has changed to a different - // view(which - // may caused by function dispose or call setup with different video - // source view) - // (_pipController.contentSource.activeVideoCallSourceView != - // currentVideoSourceView) - // This ensures the pip controller is properly configured with the current - // video source with a good user experience. - if (_pipController == nil || _pipController.contentSource == nil || - _pipController.contentSource.activeVideoCallSourceView != - currentVideoSourceView) { - - // destroy the previous video frame delegate if exists first. - [self destroyVideoFrameDelegate]; - - // create pip view - _pipView = [[AgoraPipView alloc] init]; - _pipView.autoresizingMask = - UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - - // create pip view controller - AVPictureInPictureVideoCallViewController *pipViewController = - [[AVPictureInPictureVideoCallViewController alloc] init]; - // if the preferredContentSize is not set, use 100x100 as the default size - // coz invalid size will cause the pip view to start failed with - // Domain=PGPegasusErrorDomain Code=-1003. - pipViewController.preferredContentSize = - CGSizeMake(options.preferredContentSize.width <= 0 - ? 100 - : options.preferredContentSize.width, - options.preferredContentSize.height <= 0 - ? 100 - : options.preferredContentSize.height); - pipViewController.view.backgroundColor = UIColor.clearColor; - [pipViewController.view addSubview:_pipView]; - - // create pip controller - AVPictureInPictureControllerContentSource *contentSource = - [[AVPictureInPictureControllerContentSource alloc] - initWithActiveVideoCallSourceView:currentVideoSourceView - contentViewController:pipViewController]; - - _pipController = [[AVPictureInPictureController alloc] - initWithContentSource:contentSource]; - _pipController.delegate = self; - _pipController.canStartPictureInPictureAutomaticallyFromInline = - options.autoEnterEnabled; - } - - if (options.videoCanvas != nil) { - [_pipView setMirrorMode:options.videoCanvas.mirrorMode]; - [_pipView setRenderMode:options.videoCanvas.renderMode]; - } - - [self initOrUpdateVideoFrameDelegate:_pipView - irisRendering:(agora::iris::IrisRtcRendering *) - options.renderingHandle - connection:options.connection - videoCanvas:options.videoCanvas]; - - return YES; - } - - return NO; -} - -- (BOOL)start { - AGORA_PIP_LOG(@"AgoraPipController start"); - - if (![self isSupported]) { - [_pipStateDelegate pipStateChanged:AgoraPipStateFailed - error:@"Pip is not supported"]; - return NO; - } - - // call startPictureInPicture too fast will make no effect. - dispatch_after( - dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC * 0.1), - dispatch_get_main_queue(), ^{ - if (self->_pipController == nil) { - [self->_pipStateDelegate - pipStateChanged:AgoraPipStateFailed - error:@"Pip controller is not initialized"]; - return; - } - - if (![self->_pipController isPictureInPicturePossible]) { - [self->_pipStateDelegate pipStateChanged:AgoraPipStateFailed - error:@"Pip is not possible"]; - } else if (![self->_pipController isPictureInPictureActive]) { - [self->_pipController startPictureInPicture]; - } - }); - - return YES; -} - -- (void)stop { - AGORA_PIP_LOG(@"AgoraPipController stop"); - - if (![self isSupported]) { - [_pipStateDelegate pipStateChanged:AgoraPipStateFailed - error:@"Pip is not supported"]; - return; - } - - if (self->_pipController == nil || - ![self->_pipController isPictureInPictureActive]) { - // no need to call pipStateChanged since the pip controller is not - // initialized. - return; - } - - [self->_pipController stopPictureInPicture]; -} - -- (void)dispose { - AGORA_PIP_LOG(@"AgoraPipController dispose"); - - if (self->_pipController != nil) { - // if ([self->_pipController isPictureInPictureActive]) { - // [self->_pipController stopPictureInPicture]; - // } - // - // set contentSource to nil will make pip stop immediately without any - // animation, which is more adaptive to the function of dispose, so we - // use this method to stop pip not to call stopPictureInPicture. - // - // Below is the official document of contentSource property: - // https://developer.apple.com/documentation/avkit/avpictureinpicturecontroller/contentsource-swift.property?language=objc - - if (__builtin_available(iOS 15.0, *)) { - self->_pipController.contentSource = nil; - } - - [self destroyVideoFrameDelegate]; - - // Note: do not set self->_pipController and self->_pipView to nil, - // coz this will make the pip view do not disappear immediately with - // unknown reason, which is not expected. - // - // self->_pipController = nil; - // self->_pipView = nil; - } - - if (self->_isPipActivated) { - self->_isPipActivated = NO; - [self->_pipStateDelegate pipStateChanged:AgoraPipStateStopped error:nil]; - } -} - -- (void)pictureInPictureControllerWillStartPictureInPicture: - (AVPictureInPictureController *)pictureInPictureController { - AGORA_PIP_LOG(@"pictureInPictureControllerWillStartPictureInPicture"); -} - -- (void)pictureInPictureControllerDidStartPictureInPicture: - (AVPictureInPictureController *)pictureInPictureController { - AGORA_PIP_LOG(@"pictureInPictureControllerDidStartPictureInPicture"); - - _isPipActivated = YES; - [_pipStateDelegate pipStateChanged:AgoraPipStateStarted error:nil]; -} - -- (void)pictureInPictureController: - (AVPictureInPictureController *)pictureInPictureController - failedToStartPictureInPictureWithError:(NSError *)error { - AGORA_PIP_LOG( - @"pictureInPictureController failedToStartPictureInPictureWithError: %@", - error); - [_pipStateDelegate pipStateChanged:AgoraPipStateFailed - error:error.description]; -} - -- (void)pictureInPictureControllerWillStopPictureInPicture: - (AVPictureInPictureController *)pictureInPictureController { - AGORA_PIP_LOG(@"pictureInPictureControllerWillStopPictureInPicture"); -} - -- (void)pictureInPictureControllerDidStopPictureInPicture: - (AVPictureInPictureController *)pictureInPictureController { - AGORA_PIP_LOG(@"pictureInPictureControllerDidStopPictureInPicture"); - - _isPipActivated = NO; - [_pipStateDelegate pipStateChanged:AgoraPipStateStopped error:nil]; -} - -- (void)initOrUpdateVideoFrameDelegate:(AgoraPipView *_Nonnull)pipView - irisRendering:(agora::iris::IrisRtcRendering *_Nonnull) - irisRendering - connection:(AgoraRtcConnection *_Nullable)connection - videoCanvas: - (AgoraRtcVideoCanvas *_Nullable)videoCanvas { - if (videoCanvas == nil) { - // if the videoCanvas is nil and the videoFrameDelegate is not nil, we need - // to destroy the videoFrameDelegate. - if (_videoFrameDelegate != nil) { - [self destroyVideoFrameDelegate]; - } - - return; - } - - IrisRtcVideoFrameConfig config; - config.video_frame_format = - agora::media::base::VIDEO_PIXEL_FORMAT::VIDEO_CVPIXEL_NV12; - config.uid = (decltype(config.uid))videoCanvas.uid; - config.video_source_type = - (decltype(config.video_source_type))videoCanvas.sourceType; - config.video_view_setup_mode = 1; /* AgoraVideoViewSetupAdd = 1 */ - config.observed_frame_position = - agora::media::base::VIDEO_MODULE_POSITION::POSITION_POST_CAPTURER | - agora::media::base::VIDEO_MODULE_POSITION::POSITION_PRE_RENDERER; - if (connection.channelId && (NSNull *)connection.channelId != [NSNull null]) { - strcpy(config.channelId, [connection.channelId UTF8String]); - } else { - strcpy(config.channelId, ""); - } - - if (_videoFrameDelegate != nil && - !_videoFrameDelegate->IsVideoFrameConfigEqual(config)) { - [self destroyVideoFrameDelegate]; - } - - if (_videoFrameDelegate == nil) { - _videoFrameDelegate = - new AgoraVideoFrameDelegate(pipView, irisRendering, config); - } -} - -- (void)destroyVideoFrameDelegate { - if (_videoFrameDelegate != nil) { - delete _videoFrameDelegate; - _videoFrameDelegate = nil; - } -} - -@end diff --git a/ios/Classes/AgoraRtcNgPlugin.h b/ios/Classes/AgoraRtcNgPlugin.h index 79d0e53f8..eeec1574f 100644 --- a/ios/Classes/AgoraRtcNgPlugin.h +++ b/ios/Classes/AgoraRtcNgPlugin.h @@ -1,8 +1,8 @@ #import -#import "AgoraPipController.h" +#import @interface AgoraRtcNgPlugin - : NSObject + : NSObject @end diff --git a/ios/Classes/AgoraRtcNgPlugin.m b/ios/Classes/AgoraRtcNgPlugin.m index e1520e3d1..9a963c63d 100644 --- a/ios/Classes/AgoraRtcNgPlugin.m +++ b/ios/Classes/AgoraRtcNgPlugin.m @@ -1,7 +1,16 @@ #import "AgoraRtcNgPlugin.h" #import "AgoraSurfaceViewFactory.h" +#import "AgoraUtils.h" #import "VideoViewController.h" +@interface AgoraNativeView : UIView + +@end + +@implementation AgoraNativeView + +@end + @interface AgoraRtcNgPlugin () @property(nonatomic) NSObject *registrar; @@ -10,7 +19,9 @@ @interface AgoraRtcNgPlugin () @property(nonatomic) VideoViewController *videoViewController; -@property(nonatomic) AgoraPipController *pipController; +@property(nonatomic) AgoraPIPController *pipController; + +@property(nonatomic, strong) NSMutableArray *_Nonnull nativeViews; @end @@ -19,6 +30,9 @@ + (void)registerWithRegistrar:(NSObject *)registrar { AgoraRtcNgPlugin *instance = [[AgoraRtcNgPlugin alloc] init]; instance.registrar = registrar; + // Initialize nativeViews array + instance.nativeViews = [[NSMutableArray alloc] init]; + // create method channel FlutterMethodChannel *channel = [FlutterMethodChannel methodChannelWithName:@"agora_rtc_ng" @@ -37,8 +51,24 @@ + (void)registerWithRegistrar:(NSObject *)registrar { withId:@"AgoraSurfaceView"]; // create pip controller - instance.pipController = [[AgoraPipController alloc] - initWith:(id)instance]; + instance.pipController = [[AgoraPIPController alloc] + initWith:(id)instance]; +} + +- (void)handleMethodCall:(FlutterMethodCall *)call + result:(FlutterResult)result { + AGORA_LOG(@"handleMethodCall: %@ with arguments: %@", call.method, + call.arguments); + + if ([@"getAssetAbsolutePath" isEqualToString:call.method]) { + [self getAssetAbsolutePath:call result:result]; + } else if ([call.method hasPrefix:@"pip"]) { + [self handlePipMethodCall:call result:result]; + } else if ([call.method hasPrefix:@"nativeView"]) { + [self handleNativeViewMethodCall:call result:result]; + } else { + result(FlutterMethodNotImplemented); + } } - (void)getAssetAbsolutePath:(FlutterMethodCall *)call @@ -62,17 +92,6 @@ - (void)getAssetAbsolutePath:(FlutterMethodCall *)call details:nil]); } -- (void)handleMethodCall:(FlutterMethodCall *)call - result:(FlutterResult)result { - if ([@"getAssetAbsolutePath" isEqualToString:call.method]) { - [self getAssetAbsolutePath:call result:result]; - } else if ([call.method hasPrefix:@"pip"]) { - [self handlePipMethodCall:call result:result]; - } else { - result(FlutterMethodNotImplemented); - } -} - - (void)handlePipMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { if ([@"pipIsSupported" isEqualToString:call.method]) { @@ -84,7 +103,7 @@ - (void)handlePipMethodCall:(FlutterMethodCall *)call } else if ([@"pipSetup" isEqualToString:call.method]) { @autoreleasepool { // new options - AgoraPipOptions *options = [[AgoraPipOptions alloc] init]; + AgoraPIPOptions *options = [[AgoraPIPOptions alloc] init]; // auto enter if ([call.arguments objectForKey:@"autoEnterEnabled"]) { @@ -92,6 +111,21 @@ - (void)handlePipMethodCall:(FlutterMethodCall *)call [[call.arguments objectForKey:@"autoEnterEnabled"] boolValue]; } + // contentView + if ([call.arguments objectForKey:@"contentView"]) { + options.contentView = (__bridge UIView *)[[call.arguments + objectForKey:@"contentView"] pointerValue]; + } else { + // MUST set contentView + return result([NSNumber numberWithBool:NO]); + } + + // sourceContentView + if ([call.arguments objectForKey:@"sourceContentView"]) { + options.sourceContentView = (__bridge UIView *)[[call.arguments + objectForKey:@"sourceContentView"] pointerValue]; + } + // preferred content size if ([call.arguments objectForKey:@"preferredContentWidth"] && [call.arguments objectForKey:@"preferredContentHeight"]) { @@ -101,48 +135,12 @@ - (void)handlePipMethodCall:(FlutterMethodCall *)call floatValue]); } - // connection - if ([call.arguments objectForKey:@"connection"] && - [[call.arguments objectForKey:@"connection"] - isKindOfClass:[NSDictionary class]]) { - NSDictionary *connectionDict = - [call.arguments objectForKey:@"connection"]; - options.connection = [[AgoraRtcConnection alloc] - initWithChannelId:[connectionDict objectForKey:@"channelId"] - localUid:[[connectionDict objectForKey:@"localUid"] - unsignedIntegerValue]]; - } - - // video canvas - if ([call.arguments objectForKey:@"videoCanvas"] && - [[call.arguments objectForKey:@"videoCanvas"] - isKindOfClass:[NSDictionary class]]) { - NSDictionary *videoCanvasDict = - [call.arguments objectForKey:@"videoCanvas"]; - options.videoCanvas = [[AgoraRtcVideoCanvas alloc] init]; - options.videoCanvas.uid = - [[videoCanvasDict objectForKey:@"uid"] unsignedIntegerValue]; - options.videoCanvas.view = (__bridge UIView *)[[videoCanvasDict - objectForKey:@"view"] pointerValue]; - options.videoCanvas.mirrorMode = - [[videoCanvasDict objectForKey:@"mirrorMode"] integerValue]; - options.videoCanvas.renderMode = - [[videoCanvasDict objectForKey:@"renderMode"] integerValue]; - options.videoCanvas.backgroundColor = [[videoCanvasDict - objectForKey:@"backgroundColor"] unsignedIntValue]; - options.videoCanvas.sourceType = - [[videoCanvasDict objectForKey:@"sourceType"] integerValue]; - options.videoCanvas.mediaPlayerId = - (int)[[videoCanvasDict objectForKey:@"mediaPlayerId"] integerValue]; - } - - // rendering handle pointerValue - if ([call.arguments objectForKey:@"renderingHandle"] && - [[call.arguments objectForKey:@"renderingHandle"] - isKindOfClass:[NSNumber class]]) { - options.renderingHandle = (uintptr_t)[ - [call.arguments objectForKey:@"renderingHandle"] pointerValue]; + // control style + if ([call.arguments objectForKey:@"controlStyle"]) { + options.controlStyle = + [[call.arguments objectForKey:@"controlStyle"] intValue]; } + result([NSNumber numberWithBool:[self.pipController setup:options]]); } } else if ([@"pipStart" isEqualToString:call.method]) { @@ -158,11 +156,353 @@ - (void)handlePipMethodCall:(FlutterMethodCall *)call } } -- (void)pipStateChanged:(AgoraPipState)state error:(NSString *)error { +/** + * Handles native view related method calls from Flutter. + * @param call The method call from Flutter + * @param result The result callback + */ +- (void)handleNativeViewMethodCall:(FlutterMethodCall *)call + result:(FlutterResult)result { + @autoreleasepool { + if ([@"nativeViewCreate" isEqualToString:call.method]) { + // Create a new native UIView and store it with its pointer as the ID + UIView *view = [[AgoraNativeView alloc] init]; + if (!view) { + result(nil); + return; + } + + [self.nativeViews addObject:view]; + + view.translatesAutoresizingMaskIntoConstraints = NO; + + result(@((uint64_t)view)); + } else if ([@"nativeViewDestroy" isEqualToString:call.method]) { + // Validate arguments + if (![call.arguments isKindOfClass:[NSNumber class]]) { + result(nil); + return; + } + + uint64_t viewId = [call.arguments unsignedLongLongValue]; + UIView *view = [self findNativeView:viewId]; + + if (view) { + [view removeFromSuperview]; // Remove from parent view hierarchy + [self.nativeViews removeObject:view]; // Remove from our array + view = nil; // Clear the reference + } + + result(nil); + + } else if ([@"nativeViewSetParent" isEqualToString:call.method]) { + // Validate arguments + if (![call.arguments isKindOfClass:[NSDictionary class]]) { + result(@NO); + return; + } + + NSDictionary *params = call.arguments; + NSNumber *viewIdNum = params[@"nativeViewId"]; + + if (!viewIdNum || ![viewIdNum isKindOfClass:[NSNumber class]]) { + result(@NO); + return; + } + + UIView *view = [self findNativeView:[viewIdNum unsignedLongLongValue]]; + if (!view) { + result(@NO); + return; + } + + UIView *parentView = nil; + NSNumber *parentViewIdNum = params[@"parentNativeViewId"]; + if (parentViewIdNum && [parentViewIdNum isKindOfClass:[NSNumber class]]) { + parentView = + [self findNativeView:[parentViewIdNum unsignedLongLongValue]]; + } + + // remove from previous parent view only it has a parent view and is not + // the same as the new parent view, even if the new parent view is nil + if (view.superview && view.superview != parentView) { + [view removeFromSuperview]; + } + + // if parent view is nil, return true, which means the caller only want to + // remove the view from its parent view + if (!parentView) { + result(@YES); + return; + } + + NSNumber *indexOfParentView = params[@"indexOfParentView"]; + + // if view is not in parent view, insert to new parent view at index if + // specified + if (view.superview != parentView) { + // insert to new parent view at index if specified + if (indexOfParentView && + [indexOfParentView isKindOfClass:[NSNumber class]]) { + [parentView insertSubview:view atIndex:[indexOfParentView intValue]]; + } else { + [parentView addSubview:view]; + } + } else if (indexOfParentView && + [indexOfParentView isKindOfClass:[NSNumber class]]) { + // remove and reinsert to new index if it is not the same with the + // specified index + if ([indexOfParentView intValue] != + [parentView.subviews indexOfObject:view]) { + [view removeFromSuperview]; + [parentView insertSubview:view atIndex:[indexOfParentView intValue]]; + } + } + + result(@YES); + } else if ([@"nativeViewSetLayout" isEqualToString:call.method]) { + // Validate arguments + if (![call.arguments isKindOfClass:[NSDictionary class]]) { + result(@NO); + return; + } + + NSDictionary *params = call.arguments; + NSNumber *viewIdNum = params[@"nativeViewId"]; + + if (!viewIdNum || ![viewIdNum isKindOfClass:[NSNumber class]]) { + result(@NO); + return; + } + + UIView *view = [self findNativeView:[viewIdNum unsignedLongLongValue]]; + if (!view) { + result(@NO); + return; + } + + // Handle layout properties + id contentViewLayoutObj = params[@"contentViewLayout"]; + if (contentViewLayoutObj && + [contentViewLayoutObj isKindOfClass:[NSDictionary class]]) { + [self applyContentViewLayout:(NSDictionary *)contentViewLayoutObj + toView:view]; + } + + result(@YES); + } else { + result(FlutterMethodNotImplemented); + } + } +} + +- (void)pipStateChanged:(AgoraPIPState)state error:(NSString *)error { + AGORA_LOG(@"pipStateChanged: %ld, error: %@", (long)state, error); + NSDictionary *arguments = [[NSDictionary alloc] initWithObjectsAndKeys:[NSNumber numberWithLong:(long)state], @"state", error, @"error", nil]; [self.channel invokeMethod:@"pipStateChanged" arguments:arguments]; } +- (UIView *)findNativeView:(uint64_t)viewId { + __block UIView *view; + [self.nativeViews + enumerateObjectsUsingBlock:^(UIView *obj, NSUInteger idx, BOOL *stop) { + if ((uint64_t)obj == viewId) { + view = obj; + *stop = YES; + } + }]; + return view; +} + +- (void)applyContentViewLayout:(NSDictionary *)contentViewLayout + toView:(UIView *)view { + if (!contentViewLayout || !view) { + return; + } + + // Get actual subview count + NSArray *subviews = view.subviews; + NSInteger subviewCount = subviews.count; + if (subviewCount == 0) { + return; + } + + NSNumber *padding = contentViewLayout[@"padding"]; + NSNumber *spacing = contentViewLayout[@"spacing"]; + NSNumber *row = contentViewLayout[@"row"]; + NSNumber *column = contentViewLayout[@"column"]; + + // Validate row and column values + NSInteger actualRow = row ? [row integerValue] : 0; + NSInteger actualColumn = column ? [column integerValue] : 0; + + // Rule 3: Ignore negative values + if (actualRow < 0) + actualRow = 0; + if (actualColumn < 0) + actualColumn = 0; + + // Rule 1: If row is not set or 0, calculate based on column and subview count + if (actualRow == 0) { + if (actualColumn > 0) { + actualRow = (subviewCount + actualColumn - 1) / actualColumn; + } else { + // If both row and column are not set, use a default layout + actualRow = 1; + actualColumn = subviewCount; + } + } + + // Rule 2: Column is just a reference, adjust if needed + if (actualColumn == 0) { + if (actualRow > subviewCount) { + actualColumn = 1; + } else { + actualColumn = (subviewCount + actualRow - 1) / actualRow; + } + } + + // Rule 4: If actualRow * actualColumn is less than subviewCount, adjust + // actualRow and actualColumn + if (actualRow * actualColumn < subviewCount) { + actualColumn = (subviewCount + actualRow - 1) / actualRow; + } + + // Remove existing constraints + [view removeConstraints:view.constraints]; + for (UIView *subview in subviews) { + // Only remove constraints between subview and parent view + NSArray *subviewConstraints = [subview.constraints copy]; + for (NSLayoutConstraint *constraint in subviewConstraints) { + if ((constraint.firstItem == subview && constraint.secondItem == view) || + (constraint.firstItem == view && constraint.secondItem == subview)) { + [subview removeConstraint:constraint]; + } + } + subview.translatesAutoresizingMaskIntoConstraints = NO; + } + + // Apply padding + CGFloat paddingValue = padding ? [padding doubleValue] : 0; + CGFloat spacingValue = spacing ? [spacing doubleValue] : 0; + + // Create constraints for each subview + for (NSInteger i = 0; i < subviewCount; i++) { + UIView *subview = subviews[i]; + NSInteger currentRow = i / actualColumn; + NSInteger currentColumn = i % actualColumn; + + // Width constraint - equal width for all subviews in the same column + if (currentColumn == 0) { + // First column - set width based on container width + [view addConstraint:[NSLayoutConstraint + constraintWithItem:subview + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:view + attribute:NSLayoutAttributeWidth + multiplier:1.0 / actualColumn + constant:-(spacingValue * + (actualColumn - 1) + + paddingValue * 2) / + actualColumn]]; + } else { + // Other columns - equal to first column + [view addConstraint:[NSLayoutConstraint + constraintWithItem:subview + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:subviews[i - currentColumn] + attribute:NSLayoutAttributeWidth + multiplier:1.0 + constant:0.0]]; + } + + // Height constraint - equal height for all rows, regardless of whether they + // have subviews + [view + addConstraint:[NSLayoutConstraint + constraintWithItem:subview + attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:view + attribute:NSLayoutAttributeHeight + multiplier:1.0 / actualRow + constant:-(spacingValue * (actualRow - 1) + + paddingValue * 2) / + actualRow]]; + + // Position constraints + if (currentColumn == 0) { + // First column - leading edge + [view addConstraint:[NSLayoutConstraint + constraintWithItem:subview + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:view + attribute:NSLayoutAttributeLeading + multiplier:1.0 + constant:paddingValue]]; + } else { + // Other columns - spacing from previous view + [view addConstraint:[NSLayoutConstraint + constraintWithItem:subview + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:subviews[i - 1] + attribute:NSLayoutAttributeTrailing + multiplier:1.0 + constant:spacingValue]]; + } + + if (currentRow == 0) { + // First row - top edge + [view addConstraint:[NSLayoutConstraint + constraintWithItem:subview + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:view + attribute:NSLayoutAttributeTop + multiplier:1.0 + constant:paddingValue]]; + } else { + // Other rows - spacing from row above + [view addConstraint:[NSLayoutConstraint + constraintWithItem:subview + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:subviews[i - actualColumn] + attribute:NSLayoutAttributeBottom + multiplier:1.0 + constant:spacingValue]]; + } + + // Last column - trailing edge + if (currentColumn == actualColumn - 1) { + [view addConstraint:[NSLayoutConstraint + constraintWithItem:subview + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:view + attribute:NSLayoutAttributeTrailing + multiplier:1.0 + constant:-paddingValue]]; + } + + // Last row - bottom edge + if (currentRow == actualRow - 1) { + [view addConstraint:[NSLayoutConstraint + constraintWithItem:subview + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:view + attribute:NSLayoutAttributeBottom + multiplier:1.0 + constant:-paddingValue]]; + } + } +} @end diff --git a/ios/Classes/AgoraUtils.h b/ios/Classes/AgoraUtils.h new file mode 120000 index 000000000..bfbf590fa --- /dev/null +++ b/ios/Classes/AgoraUtils.h @@ -0,0 +1 @@ +../../shared/darwin/AgoraUtils.h \ No newline at end of file diff --git a/ios/agora_rtc_engine.podspec b/ios/agora_rtc_engine.podspec index 454fdd104..5a6cec8ca 100644 --- a/ios/agora_rtc_engine.podspec +++ b/ios/agora_rtc_engine.podspec @@ -32,6 +32,8 @@ Pod::Spec.new do |s| # native dependencies start s.dependency 'AgoraRtcEngine_Special_iOS', '4.3.2.11' # native dependencies end + + s.dependency 'AgoraPIP_iOS', '0.0.2-rc.1' end s.platform = :ios, '9.0' diff --git a/lib/agora_rtc_engine.dart b/lib/agora_rtc_engine.dart index 822de18ee..0596d7a79 100644 --- a/lib/agora_rtc_engine.dart +++ b/lib/agora_rtc_engine.dart @@ -20,3 +20,4 @@ export 'src/render/media_player_controller.dart'; export 'src/render/video_view_controller.dart'; export 'src/agora_rtc_engine_ext.dart'; +export 'src/agora_pip_controller.dart'; diff --git a/lib/src/agora_h265_transcoder.dart b/lib/src/agora_h265_transcoder.dart index 54296194c..9717711ac 100644 --- a/lib/src/agora_h265_transcoder.dart +++ b/lib/src/agora_h265_transcoder.dart @@ -1,4 +1,3 @@ -import '/src/_serializable.dart'; import '/src/binding_forward_export.dart'; part 'agora_h265_transcoder.g.dart'; diff --git a/lib/src/agora_media_engine.dart b/lib/src/agora_media_engine.dart index 1977ab5ad..eb739141c 100644 --- a/lib/src/agora_media_engine.dart +++ b/lib/src/agora_media_engine.dart @@ -1,4 +1,3 @@ -import '/src/_serializable.dart'; import '/src/binding_forward_export.dart'; part 'agora_media_engine.g.dart'; diff --git a/lib/src/agora_media_player.dart b/lib/src/agora_media_player.dart index d3ba4cfcd..3bef30356 100644 --- a/lib/src/agora_media_player.dart +++ b/lib/src/agora_media_player.dart @@ -1,4 +1,3 @@ -import '/src/_serializable.dart'; import '/src/binding_forward_export.dart'; /// This class provides media player functions and supports multiple instances. diff --git a/lib/src/agora_media_player_source.dart b/lib/src/agora_media_player_source.dart index 7484092d4..023408a1e 100644 --- a/lib/src/agora_media_player_source.dart +++ b/lib/src/agora_media_player_source.dart @@ -1,4 +1,3 @@ -import '/src/_serializable.dart'; import '/src/binding_forward_export.dart'; /// Provides callbacks for media players. diff --git a/lib/src/agora_media_recorder.dart b/lib/src/agora_media_recorder.dart index 5ab360fad..7730f90f6 100644 --- a/lib/src/agora_media_recorder.dart +++ b/lib/src/agora_media_recorder.dart @@ -1,4 +1,3 @@ -import '/src/_serializable.dart'; import '/src/binding_forward_export.dart'; /// @nodoc diff --git a/lib/src/agora_pip_controller.dart b/lib/src/agora_pip_controller.dart new file mode 100644 index 000000000..c8c7321ba --- /dev/null +++ b/lib/src/agora_pip_controller.dart @@ -0,0 +1,346 @@ +import 'package:flutter/foundation.dart'; + +import '/src/agora_base.dart'; +import '/src/agora_rtc_engine_ex.dart'; + +/// Represents a video stream configuration for Picture-in-Picture (PiP) mode. +/// +/// This class holds the connection and canvas settings needed to display +/// a video stream within the PiP window. +class AgoraPipVideoStream { + /// The RTC connection associated with this video stream. + final RtcConnection connection; + + /// The video canvas configuration for rendering this stream. + final VideoCanvas canvas; + + /// Creates an [AgoraPipVideoStream] instance. + /// + /// Both [connection] and [canvas] parameters are required to properly + /// configure the video stream in PiP mode. + const AgoraPipVideoStream({required this.connection, required this.canvas}); +} + +/// Layout configuration for Picture-in-Picture (PiP) video streams. +/// +/// This class defines how multiple video streams should be arranged in a flow layout, +/// where streams are placed from left to right and top to bottom in sequence. +/// +/// Example layout with padding=10, spacing=5, column=3: +/// ``` +/// ┌────────────────────────────────────┐ +/// │ │ +/// │ ┌────┐ ┌────┐ ┌────┐ │ +/// │ │ 1 │ │ 2 │ │ 3 │ │ +/// │ └────┘ └────┘ └────┘ │ +/// │ │ +/// │ ┌────┐ ┌────┐ ┌────┐ │ +/// │ │ 4 │ │ 5 │ │ 6 │ │ +/// │ └────┘ └────┘ └────┘ │ +/// │ │ +/// │ ┌────┐ │ +/// │ │ 7 │ │ +/// │ └────┘ │ +/// │ │ +/// └────────────────────────────────────┘ +/// ``` +class AgoraPipContentViewLayout { + /// The padding around the entire layout in pixels. + /// Creates space between the layout edges and the streams. + /// If null, no padding will be applied. + final int? padding; + + /// The horizontal and vertical spacing between streams in pixels. + /// Creates consistent gaps between adjacent streams. + /// If null, streams will be placed directly adjacent to each other. + final int? spacing; + + /// Maximum number of rows allowed in the layout. + /// When reached, no more rows will be created even if more streams exist. + /// If null, rows will be created as needed to fit all streams. + /// Must be greater than 0 or null. + final int? row; + + /// Maximum number of streams per row. + /// When reached, a new row will be started. + /// If null, streams will flow to fill the available width. + /// Must be greater than 0 or null. + final int? column; + + /// Creates an [AgoraPipContentViewLayout] instance. + /// + /// The [streams] parameter is required and specifies which video streams + /// should be shown in the layout in sequential order. + const AgoraPipContentViewLayout({ + this.padding, + this.spacing, + this.row, + this.column, + }); + + Map toDictionary() { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('padding', padding); + writeNotNull('spacing', spacing); + writeNotNull('row', row); + writeNotNull('column', column); + return val; + } +} + +/// Configuration options for Agora Picture-in-Picture (PiP) mode. +/// +/// This class provides platform-specific options to configure PiP behavior +/// for both Android and iOS platforms. +class AgoraPipOptions { + /// Creates an [AgoraPipOptions] instance. + /// + /// All parameters are optional and platform-specific. + AgoraPipOptions({ + this.autoEnterEnabled, + + // Android-specific options + this.aspectRatioX, + this.aspectRatioY, + this.sourceRectHintLeft, + this.sourceRectHintTop, + this.sourceRectHintRight, + this.sourceRectHintBottom, + this.seamlessResizeEnabled, + this.useExternalStateMonitor, + this.externalStateMonitorInterval, + + // iOS-specific options + this.sourceContentView, + this.contentView, + this.videoStreams, + this.contentViewLayout, + this.preferredContentWidth, + this.preferredContentHeight, + this.controlStyle, + }); + + /// Whether to automatically enter PiP mode. + /// + /// Platform: Android only + final bool? autoEnterEnabled; + + /// The horizontal aspect ratio of the PiP window. + /// + /// Platform: Android only + final int? aspectRatioX; + + /// The vertical aspect ratio of the PiP window. + /// + /// Platform: Android only + final int? aspectRatioY; + + /// The left coordinate of the source rectangle hint. + /// + /// Used to specify the initial position of the PiP window. + /// Platform: Android only + final int? sourceRectHintLeft; + + /// The top coordinate of the source rectangle hint. + /// + /// Used to specify the initial position of the PiP window. + /// Platform: Android only + final int? sourceRectHintTop; + + /// The right coordinate of the source rectangle hint. + /// + /// Used to specify the initial position of the PiP window. + /// Platform: Android only + final int? sourceRectHintRight; + + /// The bottom coordinate of the source rectangle hint. + /// + /// Used to specify the initial position of the PiP window. + /// Platform: Android only + final int? sourceRectHintBottom; + + /// Whether to enable seamless resize for the PiP window. + /// + /// When enabled, the PiP window will resize smoothly. + /// Defaults to false. + /// Platform: Android only + final bool? seamlessResizeEnabled; + + /// Whether to use external state monitoring. + /// + /// When enabled, creates a dedicated thread to monitor PiP window state. + /// Use [externalStateMonitorInterval] to configure monitoring frequency. + /// Defaults to false. + /// Platform: Android only + final bool? useExternalStateMonitor; + + /// The interval for external state monitoring in milliseconds. + /// + /// Only takes effect when [useExternalStateMonitor] is true. + /// Defaults to 100ms. + /// Platform: Android only + final int? externalStateMonitorInterval; + + /// The source content view identifier. + /// + /// Set to 0 to use the root view as the source. + /// Platform: iOS only + final int? sourceContentView; + + /// The content view identifier for video rendering. + /// + /// Set to 0 to let the SDK manage the view. + /// When set to 0, you must provide video sources through [videoStreams]. + /// Platform: iOS only + int? contentView; + + /// Configuration for video transcoding. + /// + /// Only takes effect when [contentView] is set to 0. + /// When user let the SDK manage the view, all video streams will place in a root view in the PIP window. + /// Platform: iOS only + final List? videoStreams; + + /// Layout configuration for PiP video streams. + /// + /// Only takes effect when [contentView] is set to 0. + /// Platform: iOS only + final AgoraPipContentViewLayout? contentViewLayout; + + /// The preferred width of the PiP content. + /// + /// Platform: iOS only + final int? preferredContentWidth; + + /// The preferred height of the PiP content. + /// + /// Platform: iOS only + final int? preferredContentHeight; + + /// The control style for the PiP window. + /// + /// Available styles: + /// * 0: Show all system controls (default) + /// * 1: Hide forward and backward buttons + /// * 2: Hide play/pause button and progress bar (recommended) + /// * 3: Hide all system controls including close and restore buttons + /// + /// Platform: iOS only + final int? controlStyle; + + /// @nodoc + Map toDictionary() { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('autoEnterEnabled', autoEnterEnabled); + + // Android-specific options + if (defaultTargetPlatform == TargetPlatform.android) { + writeNotNull('aspectRatioX', aspectRatioX); + writeNotNull('aspectRatioY', aspectRatioY); + writeNotNull('sourceRectHintLeft', sourceRectHintLeft); + writeNotNull('sourceRectHintTop', sourceRectHintTop); + writeNotNull('sourceRectHintRight', sourceRectHintRight); + writeNotNull('sourceRectHintBottom', sourceRectHintBottom); + writeNotNull('seamlessResizeEnabled', seamlessResizeEnabled); + writeNotNull('useExternalStateMonitor', useExternalStateMonitor); + writeNotNull( + 'externalStateMonitorInterval', + externalStateMonitorInterval, + ); + } + + // iOS-specific options + if (defaultTargetPlatform == TargetPlatform.iOS) { + writeNotNull('sourceContentView', sourceContentView); + writeNotNull('contentView', contentView); + writeNotNull('contentViewLayout', contentViewLayout?.toDictionary()); + writeNotNull('preferredContentWidth', preferredContentWidth); + writeNotNull('preferredContentHeight', preferredContentHeight); + writeNotNull('controlStyle', controlStyle); + } + return val; + } +} + +/// Represents the current state of Picture-in-Picture mode. +enum AgoraPipState { + /// PiP mode has been successfully started. + pipStateStarted, + + /// PiP mode has been stopped. + pipStateStopped, + + /// PiP mode failed to start or encountered an error. + pipStateFailed, +} + +/// Observer for Picture-in-Picture state changes. +/// +/// Implement this class to receive notifications about PiP state transitions +/// and potential errors. +class AgoraPipStateChangedObserver { + /// Creates an observer for PiP state changes. + /// + /// The [onPipStateChanged] callback is required and will be called + /// whenever the PiP state changes. + const AgoraPipStateChangedObserver({required this.onPipStateChanged}); + + /// Callback function for PiP state changes. + /// + /// Parameters: + /// * [state] - The new PiP state + /// * [error] - Error message if the state change failed, null otherwise + final void Function(AgoraPipState state, String? error) onPipStateChanged; +} + +/// Controller interface for managing Picture-in-Picture functionality. +/// +/// This abstract class defines the methods required to control PiP mode, +/// including setup, state management, and lifecycle operations. +abstract class AgoraPipController { + /// Releases resources associated with PiP mode. + Future dispose(); + + /// Registers an observer for PiP state changes. + Future registerPipStateChangedObserver( + AgoraPipStateChangedObserver observer, + ); + + /// Unregisters a previously registered PiP state observer. + Future unregisterPipStateChangedObserver(); + + /// Checks if PiP mode is supported on the current device. + Future pipIsSupported(); + + /// Checks if automatic PiP mode entry is supported. + Future pipIsAutoEnterSupported(); + + /// Checks if PiP mode is currently active. + Future isPipActivated(); + + /// Configures PiP mode with the specified options. + Future pipSetup(AgoraPipOptions options); + + /// Starts PiP mode with the current configuration. + Future pipStart(); + + /// Stops PiP mode. + Future pipStop(); + + /// Releases resources associated with PiP mode. + Future pipDispose(); +} diff --git a/lib/src/agora_rtc_engine_ext.dart b/lib/src/agora_rtc_engine_ext.dart index d8c55c720..cbee0314b 100644 --- a/lib/src/agora_rtc_engine_ext.dart +++ b/lib/src/agora_rtc_engine_ext.dart @@ -1,157 +1,14 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show MethodCall; + import '/src/agora_media_player.dart'; import '/src/agora_rtc_engine.dart'; import '/src/agora_rtc_engine_ex.dart'; import '/src/impl/agora_rtc_engine_impl.dart'; -import '/src/agora_base.dart'; -import 'package:flutter/foundation.dart'; -import 'impl/agora_rtc_engine_impl.dart' as impl; -import 'impl/media_player_impl.dart'; - -/// Agora Picture in Picture options. -class AgoraPipOptions { - const AgoraPipOptions({ - this.autoEnterEnabled, - this.aspectRatioX, - this.aspectRatioY, - this.sourceRectHintLeft, - this.sourceRectHintTop, - this.sourceRectHintRight, - this.sourceRectHintBottom, - this.seamlessResizeEnabled, - this.useExternalStateMonitor, - this.externalStateMonitorInterval, - this.connection, - this.videoCanvas, - this.preferredContentWidth, - this.preferredContentHeight, - }); - - /// Whether to enable auto enter. - /// @note only for android - final bool? autoEnterEnabled; - - /// The aspect ratio of the video view. - /// @note only for android - final int? aspectRatioX; - - /// The aspect ratio of the video view. - /// @note only for android - final int? aspectRatioY; - - /// The left position of the source rect hint. - /// @note only for android - final int? sourceRectHintLeft; - - /// The top position of the source rect hint. - /// @note only for android - final int? sourceRectHintTop; - - /// The right position of the source rect hint. - /// @note only for android - final int? sourceRectHintRight; - - /// The bottom position of the source rect hint. - /// @note only for android - final int? sourceRectHintBottom; - - /// Whether to enable seamless resize. - /// Default is false. Set to true to enable seamless resize. - /// @note only for android - final bool? seamlessResizeEnabled; - - /// Whether to use external state monitor. - /// Default is false. Set to true to use external state monitor, which will create a new thread to monitor the state of the pip view and - /// check the pip state with the interval set in [externalStateMonitorInterval]. - /// @note only for android - final bool? useExternalStateMonitor; - - /// The interval of the external state monitor, in milliseconds. Default is 100ms. - /// @note only for android - final int? externalStateMonitorInterval; - - /// The rtc connection. - /// @note only for ios - final RtcConnection? connection; - - /// @see VideoCanvas - /// @note the view in videoCanvas is the sourceView of pip view, zero means to use the root view of the app. - /// @note only some properties of VideoCanvas are supported: - /// - uid (optional) - /// - view (optional) - /// - backgroundColor (optional) - /// - mirrorMode (optional) - /// - renderMode (optional) - /// - sourceType (optional) - /// - mediaPlayerId (optional) not supported - /// @note only for ios - final VideoCanvas? videoCanvas; - - /// The preferred content width. - /// @note only for ios - final int? preferredContentWidth; - - /// The preferred content height. - /// @note only for ios - final int? preferredContentHeight; - - /// @nodoc - Map toDictionary() { - final val = {}; - - void writeNotNull(String key, dynamic value) { - if (value != null) { - val[key] = value; - } - } - - writeNotNull('autoEnterEnabled', autoEnterEnabled); - - // only for android - if (defaultTargetPlatform == TargetPlatform.android) { - writeNotNull('aspectRatioX', aspectRatioX); - writeNotNull('aspectRatioY', aspectRatioY); - writeNotNull('sourceRectHintLeft', sourceRectHintLeft); - writeNotNull('sourceRectHintTop', sourceRectHintTop); - writeNotNull('sourceRectHintRight', sourceRectHintRight); - writeNotNull('sourceRectHintBottom', sourceRectHintBottom); - writeNotNull('seamlessResizeEnabled', seamlessResizeEnabled); - writeNotNull('useExternalStateMonitor', useExternalStateMonitor); - writeNotNull( - 'externalStateMonitorInterval', externalStateMonitorInterval); - } - - // only for ios - if (defaultTargetPlatform == TargetPlatform.iOS) { - writeNotNull('connection', connection?.toJson()); - writeNotNull('videoCanvas', videoCanvas?.toJson()); - writeNotNull('preferredContentWidth', preferredContentWidth); - writeNotNull('preferredContentHeight', preferredContentHeight); - } - return val; - } -} - -/// The state of the Picture in Picture. -enum AgoraPipState { - /// The Picture in Picture is started. - pipStateStarted, - - /// The Picture in Picture is stopped. - pipStateStopped, - - /// The Picture in Picture is failed. - pipStateFailed, -} - -/// The observer of the Picture in Picture state changed. -class AgoraPipStateChangedObserver { - const AgoraPipStateChangedObserver({ - required this.onPipStateChanged, - }); - - /// The callback of the Picture in Picture state changed. - final void Function(AgoraPipState state, String? error) onPipStateChanged; -} +import '/src/impl/agora_rtc_engine_impl.dart' as impl; +import '/src/impl/media_player_impl.dart'; +import '/src/agora_pip_controller.dart'; +import '/src/impl/agora_pip_controller_impl.dart'; /// @nodoc extension RtcEngineExt on RtcEngine { @@ -166,78 +23,114 @@ extension RtcEngineExt on RtcEngine { return impl.getAssetAbsolutePath(assetPath); } - /// Registers a Picture in Picture state change observer. + /// Creates a [AgoraPipController] instance for managing Picture-in-Picture (PiP) mode. /// - /// [observer] The Picture in Picture state change observer. - Future registerPipStateChangedObserver( - AgoraPipStateChangedObserver observer) async { - final impl = this as RtcEngineImpl; - return impl.registerPipStateChangedObserver(observer); + /// Returns + /// A [AgoraPipController] instance. + AgoraPipController createPipController() { + return AgoraPipControllerImpl(this); } - /// Unregisters a Picture in Picture state change observer. - Future unregisterPipStateChangedObserver() async { + /// Invokes a method on the native platform through the Agora method channel. + /// + /// * [method] The name of the method to invoke + /// * [arguments] Optional arguments to pass to the method + /// + /// Returns a Future that completes with the result of type T, or null if no result + @optionalTypeArgs + Future invokeAgoraMethod(String method, [dynamic arguments]) { final impl = this as RtcEngineImpl; - return impl.unregisterPipStateChangedObserver(); + return impl.invokeAgoraMethod(method, arguments); } - /// Check if Picture in Picture is supported. + /// Registers a handler for a specific method channel. /// - /// Returns - /// Whether Picture in Picture is supported. - Future pipIsSupported() async { + /// * [method] The name of the method to handle + /// * [handler] The function that will handle calls to this method + /// + /// The handler will be called when the native platform invokes the specified method. + Future registerMethodChannelHandler( + String method, + Future Function(MethodCall call) handler, + ) { final impl = this as RtcEngineImpl; - return impl.pipIsSupported(); + return impl.registerMethodChannelHandler(method, handler); } - /// Check if Picture in Picture can auto enter. + /// Unregisters a previously registered method channel handler. /// - /// Returns - /// Whether Picture in Picture can auto enter. - Future pipIsAutoEnterSupported() async { + /// * [method] The name of the method whose handler should be removed + /// * [handler] The handler function to unregister. If null, all handlers for the + /// specified method will be removed + /// + /// After unregistering, the handler will no longer be called when the method is invoked + /// from the native platform. + Future unregisterMethodChannelHandler( + String method, + Future Function(MethodCall call)? handler, + ) { final impl = this as RtcEngineImpl; - return impl.pipIsAutoEnterSupported(); + return impl.unregisterMethodChannelHandler(method, handler); } - /// Check if Picture in Picture is activated. + /// Creates a native view for use in Picture-in-Picture (PiP) mode. /// /// Returns - /// Whether Picture in Picture is activated. - Future isPipActivated() async { - final impl = this as RtcEngineImpl; - return impl.isPipActivated(); + /// A native view ID that can be used to reference the view in future method calls. + Future createNativeView() async { + final result = await invokeAgoraMethod('nativeViewCreate'); + return result ?? 0; } - /// Setup or update Picture in Picture. + /// Destroys a native view previously created through createNativeView. /// - /// [options] The options of the Picture in Picture. + /// * [nativeViewId] The ID of the native view to destroy /// - /// Returns - /// Whether Picture in Picture is setup successfully. - Future pipSetup(AgoraPipOptions options) async { - final impl = this as RtcEngineImpl; - return impl.pipSetup(options); + Future destroyNativeView(int nativeViewId) async { + await invokeAgoraMethod('nativeViewDestroy', nativeViewId); } - /// Start Picture in Picture. + /// Sets the parent view for a native view in Picture-in-Picture (PiP) mode. /// - /// Returns - /// Whether Picture in Picture is started successfully. - Future pipStart() async { - final impl = this as RtcEngineImpl; - return impl.pipStart(); - } - - /// Stop Picture in Picture. - Future pipStop() async { - final impl = this as RtcEngineImpl; - return impl.pipStop(); + /// * [nativeViewId] The ID of the native view that will be added as a child + /// * [parentNativeViewId] The ID of the parent view to add the native view to. If 0, the native view will be removed from its current parent + /// * [indexOfParentView] The index position where the native view should be inserted in the parent's child view hierarchy. Lower indices are rendered below higher indices. + /// + /// Returns a boolean indicating whether the parent-child relationship was successfully established. + /// Returns false if either view ID is invalid or if the operation fails. + Future setNativeViewParent( + int nativeViewId, + int parentNativeViewId, + int? indexOfParentView, + ) async { + final result = await invokeAgoraMethod('nativeViewSetParent', { + 'nativeViewId': nativeViewId, + 'parentNativeViewId': parentNativeViewId, + 'indexOfParentView': indexOfParentView, + }); + return result ?? false; } - /// Dispose Picture in Picture. - Future pipDispose() async { - final impl = this as RtcEngineImpl; - return impl.pipDispose(); + /// Sets the layout constraints for a native view. + /// + /// * [nativeViewId] The ID of the native view to set the layout for + /// * [contentViewLayout] A dictionary containing layout configuration, support key: + /// - padding: Distance from top edge + /// - spacing: Distance from bottom edge + /// - row: Fixed width + /// - column: Fixed height + /// + /// Returns + /// A boolean indicating whether the layout was successfully set. + Future setNativeViewLayout( + int nativeViewId, + Map? contentViewLayout, + ) async { + final result = await invokeAgoraMethod('nativeViewSetLayout', { + 'nativeViewId': nativeViewId, + 'contentViewLayout': contentViewLayout, + }); + return result ?? false; } } diff --git a/lib/src/impl/agora_pip_controller_impl.dart b/lib/src/impl/agora_pip_controller_impl.dart new file mode 100644 index 000000000..7e9e84507 --- /dev/null +++ b/lib/src/impl/agora_pip_controller_impl.dart @@ -0,0 +1,3 @@ +export '/src/impl/platform/agora_pip_controller_expect.dart' + if (dart.library.io) '/src/impl/platform/io/agora_pip_controller_actual_io.dart' + if (dart.library.js) '/src/impl/platform/web/agora_pip_controller_actual_web.dart'; diff --git a/lib/src/impl/agora_rtc_engine_impl.dart b/lib/src/impl/agora_rtc_engine_impl.dart index d3f9112cb..6aa848bd2 100644 --- a/lib/src/impl/agora_rtc_engine_impl.dart +++ b/lib/src/impl/agora_rtc_engine_impl.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'dart:typed_data'; import '/src/agora_base.dart'; @@ -42,7 +43,7 @@ import 'package:flutter/foundation.dart' defaultTargetPlatform, kIsWeb, visibleForTesting; -import 'package:flutter/services.dart' show MethodChannel; +import 'package:flutter/services.dart' show MethodCall, MethodChannel; import 'package:flutter/widgets.dart' show VoidCallback, TargetPlatform; import 'package:iris_method_channel/iris_method_channel.dart'; import 'package:meta/meta.dart'; @@ -83,8 +84,11 @@ extension RtcEngineExt on RtcEngine { IrisMethodChannel get irisMethodChannel => (this as RtcEngineImpl)._getIrisMethodChannel(); - Future setupVideoView(Object viewHandle, VideoCanvas videoCanvas, - {RtcConnection? connection}) async { + Future setupVideoView( + Object viewHandle, + VideoCanvas videoCanvas, { + RtcConnection? connection, + }) async { Object view = viewHandle; if (kIsWeb) { view = 0; @@ -104,7 +108,10 @@ extension RtcEngineExt on RtcEngine { if (newVideoCanvas.uid != 0) { if (connection != null) { await _setupRemoteVideoExCompat( - viewHandle, newVideoCanvas, connection); + viewHandle, + newVideoCanvas, + connection, + ); } else { await _setupRemoteVideoCompat(viewHandle, newVideoCanvas); } @@ -117,16 +124,23 @@ extension RtcEngineExt on RtcEngine { } Future _setupRemoteVideoExCompat( - Object viewHandle, VideoCanvas canvas, RtcConnection connection) async { + Object viewHandle, + VideoCanvas canvas, + RtcConnection connection, + ) async { if (kIsWeb) { return _setupRemoteVideoExWeb(viewHandle, canvas, connection); } - return (this as RtcEngineImpl) - .setupRemoteVideoEx(canvas: canvas, connection: connection); + return (this as RtcEngineImpl).setupRemoteVideoEx( + canvas: canvas, + connection: connection, + ); } Future _setupRemoteVideoCompat( - Object viewHandle, VideoCanvas canvas) async { + Object viewHandle, + VideoCanvas canvas, + ) async { if (kIsWeb) { return _setupRemoteVideoWeb(viewHandle, canvas); } @@ -134,15 +148,20 @@ extension RtcEngineExt on RtcEngine { } Future _setupLocalVideoCompat( - Object viewHandle, VideoCanvas canvas) async { + Object viewHandle, + VideoCanvas canvas, + ) async { if (kIsWeb) { return _setupLocalVideoWeb(canvas, viewHandle); } return setupLocalVideo(canvas); } - Map _createParamsWeb(Object viewHandle, VideoCanvas canvas, - {RtcConnection? connection}) { + Map _createParamsWeb( + Object viewHandle, + VideoCanvas canvas, { + RtcConnection? connection, + }) { // The type of the `VideoCanvas.view` is `String` on web final param = { 'canvas': canvas.toJson()..['view'] = (viewHandle as String), @@ -152,14 +171,18 @@ extension RtcEngineExt on RtcEngine { } Future _setupRemoteVideoExWeb( - Object viewHandle, VideoCanvas canvas, RtcConnection connection) async { + Object viewHandle, + VideoCanvas canvas, + RtcConnection connection, + ) async { const apiType = 'RtcEngineEx_setupRemoteVideoEx_522a409'; final param = _createParamsWeb(viewHandle, canvas, connection: connection); final List buffers = []; buffers.addAll(canvas.collectBufferList()); buffers.addAll(connection.collectBufferList()); final callApiResult = await irisMethodChannel.invokeMethod( - IrisMethodCall(apiType, jsonEncode(param), buffers: buffers)); + IrisMethodCall(apiType, jsonEncode(param), buffers: buffers), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -171,13 +194,16 @@ extension RtcEngineExt on RtcEngine { } Future _setupRemoteVideoWeb( - Object viewHandle, VideoCanvas canvas) async { + Object viewHandle, + VideoCanvas canvas, + ) async { const apiType = 'RtcEngine_setupRemoteVideo_acc9c38'; final param = _createParamsWeb(viewHandle, canvas); final List buffers = []; buffers.addAll(canvas.collectBufferList()); final callApiResult = await irisMethodChannel.invokeMethod( - IrisMethodCall(apiType, jsonEncode(param), buffers: buffers)); + IrisMethodCall(apiType, jsonEncode(param), buffers: buffers), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -189,17 +215,21 @@ extension RtcEngineExt on RtcEngine { } Future _setupLocalVideoWeb( - VideoCanvas canvas, Object viewHandle) async { + VideoCanvas canvas, + Object viewHandle, + ) async { const apiType = 'RtcEngine_setupLocalVideo_acc9c38'; // The type of the `VideoCanvas.view` is `String` on web final param = _createParamsWeb(viewHandle, canvas); final List buffers = []; buffers.addAll(canvas.collectBufferList()); - final callApiResult = await irisMethodChannel.invokeMethod(IrisMethodCall( - apiType, - jsonEncode(param), - rawBufferParams: [BufferParam(BufferParamHandle(viewHandle), 1)], - )); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall( + apiType, + jsonEncode(param), + rawBufferParams: [BufferParam(BufferParamHandle(viewHandle), 1)], + ), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -252,8 +282,12 @@ extension RtcEngineEventHandlerExExt on RtcEngineEventHandler { } extension MetadataExt on Metadata { - Metadata copyWith( - {int? uid, int? size, Uint8List? buffer, int? timeStampMs}) { + Metadata copyWith({ + int? uid, + int? size, + Uint8List? buffer, + int? timeStampMs, + }) { return Metadata( uid: uid ?? this.uid, size: size ?? this.size, @@ -349,8 +383,10 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl GlobalVideoViewControllerPlatfrom? _globalVideoViewController; GlobalVideoViewControllerPlatfrom get globalVideoViewController { - _globalVideoViewController ??= - createGlobalVideoViewController(irisMethodChannel, this); + _globalVideoViewController ??= createGlobalVideoViewController( + irisMethodChannel, + this, + ); return _globalVideoViewController!; } @@ -361,9 +397,11 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl @internal late MethodChannel engineMethodChannel; - AsyncMemoizer? _initializeCallOnce; + @internal + Map Function(MethodCall call)>> + methodChannelHandlers = {}; - AgoraPipStateChangedObserver? _pipStateChangedObserver; + AsyncMemoizer? _initializeCallOnce; static RtcEngineEx create({ Object? sharedNativeHandle, @@ -405,8 +443,9 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl } Future _initializeInternal(RtcEngineContext context) async { - await globalVideoViewController - .attachVideoFrameBufferManager(irisMethodChannel.getApiEngineHandle()); + await globalVideoViewController.attachVideoFrameBufferManager( + irisMethodChannel.getApiEngineHandle(), + ); } @override @@ -432,25 +471,21 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl engineMethodChannel.setMethodCallHandler((call) async { try { - if (call.method == 'pipStateChanged') { - final jsonMap = Map.from(call.arguments as Map); - final state = jsonMap['state'] as int; - final error = jsonMap['error'] as String?; - _pipStateChangedObserver?.onPipStateChanged( - AgoraPipState.values[state], error); - } + methodChannelHandlers[call.method]?.forEach((handler) async { + await handler(call); + }); } catch (e) { - assert(false, 'pipStateChanged error: $e'); + assert(false, 'methodChannel error: $e'); } }); if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { - await engineMethodChannel.invokeMethod('androidInit'); + await invokeAgoraMethod('androidInit'); } List args = [ if (_sharedNativeHandle != null) - SharedNativeHandleInitilizationArgProvider(_sharedNativeHandle!) + SharedNativeHandleInitilizationArgProvider(_sharedNativeHandle!), ]; assert(() { if (_mockRtcEngineProvider != null) { @@ -465,10 +500,9 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl await super.initialize(context); - await irisMethodChannel.invokeMethod(IrisMethodCall( - 'RtcEngine_setAppType', - jsonEncode({'appType': 4}), - )); + await irisMethodChannel.invokeMethod( + IrisMethodCall('RtcEngine_setAppType', jsonEncode({'appType': 4})), + ); _rtcEngineState.isInitialzed = true; _isReleased = false; @@ -511,8 +545,9 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl await _objectPool.clear(); - await _globalVideoViewController - ?.detachVideoFrameBufferManager(irisMethodChannel.getApiEngineHandle()); + await _globalVideoViewController?.detachVideoFrameBufferManager( + irisMethodChannel.getApiEngineHandle(), + ); _globalVideoViewController = null; await irisMethodChannel.unregisterEventHandlers(_rtcEngineImplScopedKey); @@ -530,49 +565,58 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl @override void registerEventHandler( - covariant RtcEngineEventHandler eventHandler) async { + covariant RtcEngineEventHandler eventHandler, + ) async { final eventHandlerWrapper = RtcEngineEventHandlerWrapper(eventHandler); final param = createParams({}); await irisMethodChannel.registerEventHandler( - ScopedEvent( - scopedKey: _rtcEngineImplScopedKey, - registerName: 'RtcEngine_registerEventHandler_5fc0465', - unregisterName: 'RtcEngine_unregisterEventHandler_5fc0465', - handler: eventHandlerWrapper), - jsonEncode(param)); + ScopedEvent( + scopedKey: _rtcEngineImplScopedKey, + registerName: 'RtcEngine_registerEventHandler_5fc0465', + unregisterName: 'RtcEngine_unregisterEventHandler_5fc0465', + handler: eventHandlerWrapper, + ), + jsonEncode(param), + ); } @override void unregisterEventHandler( - covariant RtcEngineEventHandler eventHandler) async { + covariant RtcEngineEventHandler eventHandler, + ) async { final eventHandlerWrapper = RtcEngineEventHandlerWrapper(eventHandler); final param = createParams({}); await irisMethodChannel.unregisterEventHandler( - ScopedEvent( - scopedKey: _rtcEngineImplScopedKey, - registerName: 'RtcEngine_registerEventHandler_5fc0465', - unregisterName: 'RtcEngine_unregisterEventHandler_5fc0465', - handler: eventHandlerWrapper), - jsonEncode(param)); + ScopedEvent( + scopedKey: _rtcEngineImplScopedKey, + registerName: 'RtcEngine_registerEventHandler_5fc0465', + unregisterName: 'RtcEngine_unregisterEventHandler_5fc0465', + handler: eventHandlerWrapper, + ), + jsonEncode(param), + ); } @override Future createMediaPlayer() async { const apiType = 'RtcEngine_createMediaPlayer'; final param = createParams({}); - final callApiResult = await irisMethodChannel - .invokeMethod(IrisMethodCall(apiType, jsonEncode(param))); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall(apiType, jsonEncode(param)), + ); if (callApiResult.irisReturnCode < 0) { return null; } final rm = callApiResult.data; final result = rm['result']; -// TODO(littlegnal): Manage the mediaPlayer in scopedObjects + // TODO(littlegnal): Manage the mediaPlayer in scopedObjects final MediaPlayer mediaPlayer = media_player_impl.MediaPlayerImpl.create( - result as int, irisMethodChannel); + result as int, + irisMethodChannel, + ); return mediaPlayer; } @@ -581,8 +625,9 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl const apiType = 'RtcEngine_destroyMediaPlayer_328a49b'; final playerId = mediaPlayer.getMediaPlayerId(); final param = createParams({'playerId': playerId}); - final callApiResult = await irisMethodChannel - .invokeMethod(IrisMethodCall(apiType, jsonEncode(param))); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall(apiType, jsonEncode(param)), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -594,8 +639,10 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl } @override - Future enableEncryption( - {required bool enabled, required EncryptionConfig config}) async { + Future enableEncryption({ + required bool enabled, + required EncryptionConfig config, + }) async { const apiType = 'RtcEngine_enableEncryption_421c27b'; final configJsonMap = config.toJson(); if (config.encryptionKdfSalt != null) { @@ -604,8 +651,9 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl final param = createParams({'enabled': enabled, 'config': configJsonMap}); - final callApiResult = await irisMethodChannel - .invokeMethod(IrisMethodCall(apiType, jsonEncode(param))); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall(apiType, jsonEncode(param)), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -617,10 +665,11 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl } @override - Future enableEncryptionEx( - {required RtcConnection connection, - required bool enabled, - required EncryptionConfig config}) async { + Future enableEncryptionEx({ + required RtcConnection connection, + required bool enabled, + required EncryptionConfig config, + }) async { const apiType = 'RtcEngineEx_enableEncryptionEx_10cd872'; final configJsonMap = config.toJson(); if (config.encryptionKdfSalt != null) { @@ -630,11 +679,12 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl final param = createParams({ 'connection': connection.toJson(), 'enabled': enabled, - 'config': configJsonMap + 'config': configJsonMap, }); - final callApiResult = await irisMethodChannel - .invokeMethod(IrisMethodCall(apiType, jsonEncode(param))); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall(apiType, jsonEncode(param)), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -646,18 +696,20 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl } @override - Future> getScreenCaptureSources( - {required SIZE thumbSize, - required SIZE iconSize, - required bool includeScreen}) async { + Future> getScreenCaptureSources({ + required SIZE thumbSize, + required SIZE iconSize, + required bool includeScreen, + }) async { const apiType = 'RtcEngine_getScreenCaptureSources_f3e02cb'; final param = createParams({ 'thumbSize': thumbSize.toJson(), 'iconSize': iconSize.toJson(), - 'includeScreen': includeScreen + 'includeScreen': includeScreen, }); - final callApiResult = await irisMethodChannel - .invokeMethod(IrisMethodCall(apiType, jsonEncode(param))); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall(apiType, jsonEncode(param)), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -686,60 +738,76 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl output.add(info); } - await irisMethodChannel.invokeMethod(IrisMethodCall( + await irisMethodChannel.invokeMethod( + IrisMethodCall( 'RtcEngine_releaseScreenCaptureSources', - jsonEncode({'sources': sourcesIntPtr}))); + jsonEncode({'sources': sourcesIntPtr}), + ), + ); return output; } @override - void registerMediaMetadataObserver( - {required MetadataObserver observer, required MetadataType type}) async { + void registerMediaMetadataObserver({ + required MetadataObserver observer, + required MetadataType type, + }) async { final eventHandlerWrapper = MetadataObserverWrapper(observer); final param = createParams({'type': type.value()}); await irisMethodChannel.registerEventHandler( - ScopedEvent( - scopedKey: _rtcEngineImplScopedKey, - registerName: 'RtcEngine_registerMediaMetadataObserver_8701fec', - unregisterName: 'RtcEngine_unregisterMediaMetadataObserver_8701fec', - handler: eventHandlerWrapper), - jsonEncode(param)); + ScopedEvent( + scopedKey: _rtcEngineImplScopedKey, + registerName: 'RtcEngine_registerMediaMetadataObserver_8701fec', + unregisterName: 'RtcEngine_unregisterMediaMetadataObserver_8701fec', + handler: eventHandlerWrapper, + ), + jsonEncode(param), + ); } @override - void unregisterMediaMetadataObserver( - {required MetadataObserver observer, required MetadataType type}) async { + void unregisterMediaMetadataObserver({ + required MetadataObserver observer, + required MetadataType type, + }) async { final eventHandlerWrapper = MetadataObserverWrapper(observer); final param = createParams({'type': type.value()}); await irisMethodChannel.unregisterEventHandler( - ScopedEvent( - scopedKey: _rtcEngineImplScopedKey, - registerName: 'RtcEngine_registerMediaMetadataObserver_8701fec', - unregisterName: 'RtcEngine_unregisterMediaMetadataObserver_8701fec', - handler: eventHandlerWrapper), - jsonEncode(param)); + ScopedEvent( + scopedKey: _rtcEngineImplScopedKey, + registerName: 'RtcEngine_registerMediaMetadataObserver_8701fec', + unregisterName: 'RtcEngine_unregisterMediaMetadataObserver_8701fec', + handler: eventHandlerWrapper, + ), + jsonEncode(param), + ); } @override - Future startDirectCdnStreaming( - {required DirectCdnStreamingEventHandler eventHandler, - required String publishUrl, - required DirectCdnStreamingMediaOptions options}) async { + Future startDirectCdnStreaming({ + required DirectCdnStreamingEventHandler eventHandler, + required String publishUrl, + required DirectCdnStreamingMediaOptions options, + }) async { _directCdnStreamingEventHandlerWrapper = DirectCdnStreamingEventHandlerWrapper(eventHandler); - final param = - createParams({'publishUrl': publishUrl, 'options': options.toJson()}); + final param = createParams({ + 'publishUrl': publishUrl, + 'options': options.toJson(), + }); await irisMethodChannel.registerEventHandler( - ScopedEvent( - scopedKey: _rtcEngineImplScopedKey, - registerName: 'RtcEngine_startDirectCdnStreaming_ed8d77b', - unregisterName: 'RtcEngine_stopDirectCdnStreaming', - handler: _directCdnStreamingEventHandlerWrapper!), - jsonEncode(param)); + ScopedEvent( + scopedKey: _rtcEngineImplScopedKey, + registerName: 'RtcEngine_startDirectCdnStreaming_ed8d77b', + unregisterName: 'RtcEngine_stopDirectCdnStreaming', + handler: _directCdnStreamingEventHandlerWrapper!, + ), + jsonEncode(param), + ); } @override @@ -750,12 +818,14 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl final param = createParams({}); await irisMethodChannel.unregisterEventHandler( - ScopedEvent( - scopedKey: _rtcEngineImplScopedKey, - registerName: 'RtcEngine_startDirectCdnStreaming_ed8d77b', - unregisterName: 'RtcEngine_stopDirectCdnStreaming', - handler: _directCdnStreamingEventHandlerWrapper!), - jsonEncode(param)); + ScopedEvent( + scopedKey: _rtcEngineImplScopedKey, + registerName: 'RtcEngine_startDirectCdnStreaming_ed8d77b', + unregisterName: 'RtcEngine_stopDirectCdnStreaming', + handler: _directCdnStreamingEventHandlerWrapper!, + ), + jsonEncode(param), + ); _directCdnStreamingEventHandlerWrapper = null; } @@ -773,17 +843,20 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl @override MediaEngine getMediaEngine() { return _objectPool.putIfAbsent( - const TypedScopedKey(MediaEngineImpl), - () => media_engine_impl.MediaEngineImpl.create(irisMethodChannel)); + const TypedScopedKey(MediaEngineImpl), + () => media_engine_impl.MediaEngineImpl.create(irisMethodChannel), + ); } @override LocalSpatialAudioEngine getLocalSpatialAudioEngine() { return _objectPool .putIfAbsent( - const TypedScopedKey(LocalSpatialAudioEngineImpl), - () => agora_spatial_audio_impl.LocalSpatialAudioEngineImpl.create( - irisMethodChannel)); + const TypedScopedKey(LocalSpatialAudioEngineImpl), + () => agora_spatial_audio_impl.LocalSpatialAudioEngineImpl.create( + irisMethodChannel, + ), + ); } @override @@ -800,8 +873,9 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl Future getVersion() async { const apiType = 'RtcEngine_getVersion_915cb25'; final param = createParams({}); - final callApiResult = await irisMethodChannel - .invokeMethod(IrisMethodCall(apiType, jsonEncode(param))); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall(apiType, jsonEncode(param)), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -816,8 +890,9 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl ? 'RtcEngine_leaveChannel' : 'RtcEngine_leaveChannel_2c0e3aa'; final param = createParams({'options': options?.toJson()}); - final callApiResult = await irisMethodChannel - .invokeMethod(IrisMethodCall(apiType, jsonEncode(param))); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall(apiType, jsonEncode(param)), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -829,15 +904,20 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl } @override - Future setClientRole( - {required ClientRoleType role, ClientRoleOptions? options}) async { + Future setClientRole({ + required ClientRoleType role, + ClientRoleOptions? options, + }) async { final apiType = options == null ? 'RtcEngine_setClientRole_3426fa6' : 'RtcEngine_setClientRole_b46cc48'; - final param = - createParams({'role': role.value(), 'options': options?.toJson()}); - final callApiResult = await irisMethodChannel - .invokeMethod(IrisMethodCall(apiType, jsonEncode(param))); + final param = createParams({ + 'role': role.value(), + 'options': options?.toJson(), + }); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall(apiType, jsonEncode(param)), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -855,20 +935,22 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl } @override - Future enableDualStreamMode( - {required bool enabled, - VideoSourceType sourceType = VideoSourceType.videoSourceCameraPrimary, - SimulcastStreamConfig? streamConfig}) async { + Future enableDualStreamMode({ + required bool enabled, + VideoSourceType sourceType = VideoSourceType.videoSourceCameraPrimary, + SimulcastStreamConfig? streamConfig, + }) async { final apiType = streamConfig == null ? 'RtcEngine_enableDualStreamMode_5039d15' : 'RtcEngine_enableDualStreamMode_9822d8a'; final param = createParams({ 'enabled': enabled, 'sourceType': sourceType.value(), - 'streamConfig': streamConfig?.toJson() + 'streamConfig': streamConfig?.toJson(), }); - final callApiResult = await irisMethodChannel - .invokeMethod(IrisMethodCall(apiType, jsonEncode(param))); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall(apiType, jsonEncode(param)), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -880,16 +962,20 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl } @override - Future setDualStreamMode( - {required SimulcastStreamMode mode, - SimulcastStreamConfig? streamConfig}) async { + Future setDualStreamMode({ + required SimulcastStreamMode mode, + SimulcastStreamConfig? streamConfig, + }) async { final apiType = streamConfig == null ? 'RtcEngine_setDualStreamMode_3a7f662' : 'RtcEngine_setDualStreamMode_b3a4f6c'; - final param = createParams( - {'mode': mode.value(), 'streamConfig': streamConfig?.toJson()}); - final callApiResult = await irisMethodChannel - .invokeMethod(IrisMethodCall(apiType, jsonEncode(param))); + final param = createParams({ + 'mode': mode.value(), + 'streamConfig': streamConfig?.toJson(), + }); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall(apiType, jsonEncode(param)), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -901,11 +987,12 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl } @override - Future joinChannelWithUserAccount( - {required String token, - required String channelId, - required String userAccount, - ChannelMediaOptions? options}) async { + Future joinChannelWithUserAccount({ + required String token, + required String channelId, + required String userAccount, + ChannelMediaOptions? options, + }) async { final apiType = options == null ? 'RtcEngine_joinChannelWithUserAccount_0e4f59e' : 'RtcEngine_joinChannelWithUserAccount_4685af9'; @@ -913,10 +1000,11 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl 'token': token, 'channelId': channelId, 'userAccount': userAccount, - 'options': options?.toJson() + 'options': options?.toJson(), }); - final callApiResult = await irisMethodChannel - .invokeMethod(IrisMethodCall(apiType, jsonEncode(param))); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall(apiType, jsonEncode(param)), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -928,34 +1016,40 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl } @override - void registerAudioEncodedFrameObserver( - {required AudioEncodedFrameObserverConfig config, - required AudioEncodedFrameObserver observer}) async { + void registerAudioEncodedFrameObserver({ + required AudioEncodedFrameObserverConfig config, + required AudioEncodedFrameObserver observer, + }) async { final eventHandlerWrapper = AudioEncodedFrameObserverWrapper(observer); final param = createParams({'config': config.toJson()}); await irisMethodChannel.registerEventHandler( - ScopedEvent( - scopedKey: _rtcEngineImplScopedKey, - registerName: 'RtcEngine_registerAudioEncodedFrameObserver_ed4a177', - unregisterName: 'RtcEngine_unregisterAudioEncodedFrameObserver', - handler: eventHandlerWrapper), - jsonEncode(param)); + ScopedEvent( + scopedKey: _rtcEngineImplScopedKey, + registerName: 'RtcEngine_registerAudioEncodedFrameObserver_ed4a177', + unregisterName: 'RtcEngine_unregisterAudioEncodedFrameObserver', + handler: eventHandlerWrapper, + ), + jsonEncode(param), + ); } @override void unregisterAudioEncodedFrameObserver( - AudioEncodedFrameObserver observer) async { + AudioEncodedFrameObserver observer, + ) async { final eventHandlerWrapper = AudioEncodedFrameObserverWrapper(observer); final param = createParams({}); await irisMethodChannel.unregisterEventHandler( - ScopedEvent( - scopedKey: _rtcEngineImplScopedKey, - registerName: 'RtcEngine_registerAudioEncodedFrameObserver_ed4a177', - unregisterName: 'RtcEngine_unregisterAudioEncodedFrameObserver', - handler: eventHandlerWrapper), - jsonEncode(param)); + ScopedEvent( + scopedKey: _rtcEngineImplScopedKey, + registerName: 'RtcEngine_registerAudioEncodedFrameObserver_ed4a177', + unregisterName: 'RtcEngine_unregisterAudioEncodedFrameObserver', + handler: eventHandlerWrapper, + ), + jsonEncode(param), + ); } @override @@ -964,12 +1058,14 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl final param = createParams({}); await irisMethodChannel.registerEventHandler( - ScopedEvent( - scopedKey: _rtcEngineImplScopedKey, - registerName: 'RtcEngine_registerAudioSpectrumObserver_0406ea7', - unregisterName: 'RtcEngine_unregisterAudioSpectrumObserver_0406ea7', - handler: eventHandlerWrapper), - jsonEncode(param)); + ScopedEvent( + scopedKey: _rtcEngineImplScopedKey, + registerName: 'RtcEngine_registerAudioSpectrumObserver_0406ea7', + unregisterName: 'RtcEngine_unregisterAudioSpectrumObserver_0406ea7', + handler: eventHandlerWrapper, + ), + jsonEncode(param), + ); } @override @@ -978,12 +1074,14 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl final param = createParams({}); await irisMethodChannel.unregisterEventHandler( - ScopedEvent( - scopedKey: _rtcEngineImplScopedKey, - registerName: 'RtcEngine_registerAudioSpectrumObserver_0406ea7', - unregisterName: 'RtcEngine_unregisterAudioSpectrumObserver_0406ea7', - handler: eventHandlerWrapper), - jsonEncode(param)); + ScopedEvent( + scopedKey: _rtcEngineImplScopedKey, + registerName: 'RtcEngine_registerAudioSpectrumObserver_0406ea7', + unregisterName: 'RtcEngine_unregisterAudioSpectrumObserver_0406ea7', + handler: eventHandlerWrapper, + ), + jsonEncode(param), + ); } @override @@ -992,8 +1090,9 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl '${isOverrideClassName ? className : 'RtcEngine'}_getNativeHandle'; final param = createParams({}); - final callApiResult = await irisMethodChannel - .invokeMethod(IrisMethodCall(apiType, jsonEncode(param))); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall(apiType, jsonEncode(param)), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } @@ -1016,8 +1115,10 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl resultStr = s; } } - final nativeHandleIntPtr = - resultStr.substring(resultStr.indexOf(':') + 1, resultStr.length - 1); + final nativeHandleIntPtr = resultStr.substring( + resultStr.indexOf(':') + 1, + resultStr.length - 1, + ); int nativeHandleBIHexInt = _string2IntPtr(nativeHandleIntPtr); @@ -1032,14 +1133,17 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl final List buffers = []; buffers.addAll(info.collectBufferList()); final callApiResult = await irisMethodChannel.invokeMethod( - IrisMethodCall(apiType, jsonEncode(param), buffers: buffers)); + IrisMethodCall(apiType, jsonEncode(param), buffers: buffers), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } final rm = callApiResult.data; final result = rm['result']; return media_recorder_impl.MediaRecorderImpl.fromNativeHandle( - irisMethodChannel, result); + irisMethodChannel, + result, + ); } @override @@ -1052,7 +1156,8 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl '${isOverrideClassName ? className : 'RtcEngine'}_destroyMediaRecorder_95cdef5'; final param = createParams({'nativeHandle': impl.strNativeHandle}); await irisMethodChannel.invokeMethod( - IrisMethodCall(apiType, jsonEncode(param), buffers: null)); + IrisMethodCall(apiType, jsonEncode(param), buffers: null), + ); } Future getAssetAbsolutePath(String assetPath) async { @@ -1061,63 +1166,45 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl return 'assets/$assetPath'; } - final p = await engineMethodChannel.invokeMethod( - 'getAssetAbsolutePath', assetPath); + final p = await invokeAgoraMethod( + 'getAssetAbsolutePath', + assetPath, + ); return p; } - //////////// pip //////////// - - Future registerPipStateChangedObserver( - AgoraPipStateChangedObserver observer) async { - _pipStateChangedObserver = observer; - } - - Future unregisterPipStateChangedObserver() async { - _pipStateChangedObserver = null; - } - - Future pipIsSupported() async { - final result = - await engineMethodChannel.invokeMethod('pipIsSupported', null); - return result ?? false; - } - - Future pipIsAutoEnterSupported() async { - final result = await engineMethodChannel.invokeMethod( - 'pipIsAutoEnterSupported', null); - return result ?? false; - } - - Future isPipActivated() async { - final result = - await engineMethodChannel.invokeMethod('pipIsActivated', null); - return result ?? false; + @optionalTypeArgs + Future invokeAgoraMethod(String method, [dynamic arguments]) { + if (engineMethodChannel == null) { + throw AgoraRtcException(code: -ErrorCodeType.errInvalidState.value()); + } + return engineMethodChannel!.invokeMethod(method, arguments); } - Future pipSetup(AgoraPipOptions options) async { - // append globalVideoViewController.irisRtcRenderingHandle to json - final dicOptions = options.toDictionary(); - dicOptions['renderingHandle'] = - _globalVideoViewController!.irisRtcRenderingHandle; - - final result = - await engineMethodChannel.invokeMethod('pipSetup', dicOptions); - return result ?? false; - } + Future registerMethodChannelHandler( + String method, + Future Function(MethodCall call) handler, + ) { + methodChannelHandlers[method] ??= []; + methodChannelHandlers[method]!.add(handler); - Future pipStart() async { - final result = - await engineMethodChannel.invokeMethod('pipStart', null); - return result ?? false; + return Future.value(); } - Future pipStop() async { - await engineMethodChannel.invokeMethod('pipStop', null); - } + Future unregisterMethodChannelHandler( + String method, + Future Function(MethodCall call)? handler, + ) { + if (handler == null) { + methodChannelHandlers.remove(method); + } else { + methodChannelHandlers[method]?.remove(handler); + if (methodChannelHandlers[method]?.isEmpty == true) { + methodChannelHandlers.remove(method); + } + } - Future pipDispose() async { - await engineMethodChannel.invokeMethod('pipDispose', null); + return Future.value(); } /////////// debug //////// @@ -1125,7 +1212,8 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl /// [type] see [VideoSourceType], only [VideoSourceType.videoSourceCamera], [VideoSourceType.videoSourceRemote] supported Future startDumpVideo(int type, String dir) async { await setParameters( - "{\"engine.video.enable_video_dump\":{\"mode\": 0, \"enable\": true"); + "{\"engine.video.enable_video_dump\":{\"mode\": 0, \"enable\": true", + ); // await irisMethodChannel.invokeMethod(IrisMethodCall( // 'StartDumpVideo', @@ -1139,7 +1227,8 @@ class RtcEngineImpl extends rtc_engine_ex_binding.RtcEngineExImpl Future stopDumpVideo() async { await setParameters( - "{\"engine.video.enable_video_dump\":{\"mode\": 0, \"enable\": false"); + "{\"engine.video.enable_video_dump\":{\"mode\": 0, \"enable\": false", + ); // await irisMethodChannel.invokeMethod(IrisMethodCall( // 'StopDumpVideo', @@ -1159,7 +1248,9 @@ class VideoDeviceManagerImpl extends rtc_engine_binding.VideoDeviceManagerImpl factory VideoDeviceManagerImpl.create(RtcEngine rtcEngine) { return rtcEngine.objectPool.putIfAbsent( - _videoDeviceManagerKey, () => VideoDeviceManagerImpl._(rtcEngine)); + _videoDeviceManagerKey, + () => VideoDeviceManagerImpl._(rtcEngine), + ); } static const _videoDeviceManagerKey = TypedScopedKey(VideoDeviceManagerImpl); @@ -1169,8 +1260,9 @@ class VideoDeviceManagerImpl extends rtc_engine_binding.VideoDeviceManagerImpl const apiType = 'VideoDeviceManager_enumerateVideoDevices'; final param = createParams({}); - final callApiResult = await irisMethodChannel - .invokeMethod(IrisMethodCall(apiType, jsonEncode(param))); + final callApiResult = await irisMethodChannel.invokeMethod( + IrisMethodCall(apiType, jsonEncode(param)), + ); if (callApiResult.irisReturnCode < 0) { throw AgoraRtcException(code: callApiResult.irisReturnCode); } diff --git a/lib/src/impl/platform/agora_pip_controller_expect.dart b/lib/src/impl/platform/agora_pip_controller_expect.dart new file mode 100644 index 000000000..be80e13e2 --- /dev/null +++ b/lib/src/impl/platform/agora_pip_controller_expect.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; + +import '/src/agora_rtc_engine.dart'; +import '/src/agora_pip_controller.dart'; + +class AgoraPipControllerImpl extends AgoraPipController { + AgoraPipControllerImpl(RtcEngine rtcEngine) { + throw UnimplementedError(); + } + + @override + Future dispose() { + throw UnimplementedError(); + } + + @override + Future registerPipStateChangedObserver( + AgoraPipStateChangedObserver observer, + ) { + throw UnimplementedError(); + } + + @override + Future unregisterPipStateChangedObserver() { + throw UnimplementedError(); + } + + @override + Future pipIsSupported() { + throw UnimplementedError(); + } + + @override + Future pipIsAutoEnterSupported() { + throw UnimplementedError(); + } + + @override + Future isPipActivated() { + throw UnimplementedError(); + } + + @override + Future pipSetup(AgoraPipOptions options) { + throw UnimplementedError(); + } + + @override + Future pipStart() { + throw UnimplementedError(); + } + + @override + Future pipStop() { + throw UnimplementedError(); + } + + @override + Future pipDispose() { + throw UnimplementedError(); + } +} diff --git a/lib/src/impl/platform/io/agora_pip_controller_actual_io.dart b/lib/src/impl/platform/io/agora_pip_controller_actual_io.dart new file mode 100644 index 000000000..3cf52b77b --- /dev/null +++ b/lib/src/impl/platform/io/agora_pip_controller_actual_io.dart @@ -0,0 +1,256 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; + +import '/src/agora_base.dart'; +import '/src/agora_rtc_engine.dart'; +import '/src/agora_rtc_engine_ext.dart'; +import '/src/agora_pip_controller.dart'; +import '/src/impl/agora_rtc_engine_impl.dart'; + +class AgoraPipNativeViewPair { + final int nativeView; + final AgoraPipVideoStream videoStream; + + AgoraPipNativeViewPair(this.nativeView, this.videoStream); +} + +class AgoraPipControllerImpl extends AgoraPipController { + final RtcEngine _rtcEngine; + AgoraPipStateChangedObserver? _pipStateChangedObserver; + + int _pipContentView = 0; + final Map _pipSubViews = {}; + + AgoraPipControllerImpl(this._rtcEngine) { + _rtcEngine.registerMethodChannelHandler('pipStateChanged', (call) { + final jsonMap = Map.from(call.arguments as Map); + final stateIndex = jsonMap['state'] as int; + final state = AgoraPipState.values[stateIndex]; + final error = jsonMap['error'] as String?; + _pipStateChangedObserver?.onPipStateChanged(state, error); + return Future.value(); + }); + } + + @override + Future dispose() async { + await pipDispose(); + await _rtcEngine.unregisterMethodChannelHandler('pipStateChanged', null); + } + + @override + Future registerPipStateChangedObserver( + AgoraPipStateChangedObserver observer, + ) { + _pipStateChangedObserver = observer; + return Future.value(); + } + + @override + Future unregisterPipStateChangedObserver() { + _pipStateChangedObserver = null; + return Future.value(); + } + + @override + Future pipIsSupported() async { + final result = await _rtcEngine.invokeAgoraMethod('pipIsSupported'); + return result ?? false; + } + + @override + Future pipIsAutoEnterSupported() async { + final result = await _rtcEngine.invokeAgoraMethod( + 'pipIsAutoEnterSupported', + ); + return result ?? false; + } + + @override + Future isPipActivated() async { + final result = await _rtcEngine.invokeAgoraMethod('pipIsActivated'); + return result ?? false; + } + + Future _disposeNativeViews() async { + if (_pipSubViews.isNotEmpty) { + await Future.wait( + _pipSubViews.values.map((pair) async { + await _rtcEngine.setupVideoView( + pair.nativeView, + VideoCanvas( + view: pair.nativeView, + uid: pair.videoStream.canvas.uid, + sourceType: pair.videoStream.canvas.sourceType, + setupMode: VideoViewSetupMode.videoViewSetupRemove, + ), + connection: pair.videoStream.connection, + ); + await _rtcEngine.destroyNativeView(pair.nativeView); + }), + ); + _pipSubViews.clear(); + } + + if (_pipContentView != 0) { + await _rtcEngine.destroyNativeView(_pipContentView); + _pipContentView = 0; + } + } + + Future _pipSetupForIos(AgoraPipOptions options) async { + if ((options.contentView == null || options.contentView == 0) && + (options.videoStreams == null || options.videoStreams!.isEmpty)) { + throw AgoraRtcException(code: -ErrorCodeType.errInvalidArgument.value()); + } + + int pipContentView = 0; + + if (options.contentView != null && options.contentView != 0) { + pipContentView = options.contentView!; + if (_pipContentView != 0) { + await _disposeNativeViews(); + } + } else { + pipContentView = _pipContentView; + } + + // if pip content view is still 0, create a new native view + if (pipContentView == 0) { + pipContentView = await _rtcEngine.createNativeView(); + + // set _pipContentView to the new native view + _pipContentView = pipContentView; + } + + // if pip content view is not created, return false + if (pipContentView == 0) { + return false; + } + + // get removed native views in _pipSubViews but not in options.videoStreams, clear them first + final removedNativeViews = _pipSubViews.keys + .where( + (uid) => !options.videoStreams!.any( + (videoStream) => videoStream.canvas.uid == uid, + ), + ) + .toList(); + + // remove removed native views + for (var uid in removedNativeViews) { + final videoStream = _pipSubViews[uid]!.videoStream; + final nativeView = _pipSubViews[uid]!.nativeView; + + await _rtcEngine.setupVideoView( + nativeView, + VideoCanvas( + view: nativeView, + uid: uid, + sourceType: videoStream.canvas.sourceType, + setupMode: VideoViewSetupMode.videoViewSetupRemove, + ), + connection: videoStream.connection, + ); + await _rtcEngine.destroyNativeView(nativeView); + _pipSubViews.remove(uid); + } + + for (var i = 0; i < options.videoStreams!.length; i++) { + final videoStream = options.videoStreams![i]; + if (videoStream.canvas.uid == null) { + continue; + } + + late final int nativeView; + + // if not exists, create new native view + if (!_pipSubViews.containsKey(videoStream.canvas.uid!)) { + nativeView = await _rtcEngine.createNativeView(); + if (nativeView == 0) { + continue; + } + + _pipSubViews[videoStream.canvas.uid!] = AgoraPipNativeViewPair( + nativeView, + videoStream, + ); + + final newCanvas = VideoCanvas( + uid: videoStream.canvas.uid, + subviewUid: videoStream.canvas.subviewUid, + view: nativeView, // only replace view, other properties keep the same + backgroundColor: videoStream.canvas.backgroundColor, + renderMode: videoStream.canvas.renderMode, + mirrorMode: videoStream.canvas.mirrorMode, + setupMode: videoStream.canvas.setupMode, + sourceType: videoStream.canvas.sourceType, + mediaPlayerId: videoStream.canvas.mediaPlayerId, + cropArea: videoStream.canvas.cropArea, + enableAlphaMask: videoStream.canvas.enableAlphaMask, + position: videoStream.canvas.position, + ); + + await _rtcEngine.setupVideoView( + nativeView, + newCanvas, + connection: videoStream.connection, + ); + } else { + nativeView = _pipSubViews[videoStream.canvas.uid]!.nativeView; + } + + await _rtcEngine.setNativeViewParent(nativeView, pipContentView, i); + } + + if (_pipContentView != 0) { + await _rtcEngine.setNativeViewLayout( + _pipContentView, + options.contentViewLayout?.toDictionary(), + ); + } + + options.contentView = pipContentView; + final result = await _rtcEngine.invokeAgoraMethod( + 'pipSetup', + options.toDictionary(), + ); + + return result ?? false; + } + + @override + Future pipSetup(AgoraPipOptions options) async { + // for iOS, we need to setup the pip view with extra parameters + if (defaultTargetPlatform == TargetPlatform.iOS) { + return _pipSetupForIos(options); + } + + // for other platforms, we can use the default setup method + final result = await _rtcEngine.invokeAgoraMethod( + 'pipSetup', + options.toDictionary(), + ); + + return result ?? false; + } + + @override + Future pipStart() async { + final result = await _rtcEngine.invokeAgoraMethod('pipStart'); + return result ?? false; + } + + @override + Future pipStop() async { + await _rtcEngine.invokeAgoraMethod('pipStop'); + } + + @override + Future pipDispose() async { + await _disposeNativeViews(); + await _rtcEngine.invokeAgoraMethod('pipDispose'); + } +} diff --git a/lib/src/impl/platform/web/agora_pip_controller_actual_web.dart b/lib/src/impl/platform/web/agora_pip_controller_actual_web.dart new file mode 100644 index 000000000..fb1f9dfbb --- /dev/null +++ b/lib/src/impl/platform/web/agora_pip_controller_actual_web.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart'; + +import '/src/agora_rtc_engine.dart'; +import '/src/agora_pip_controller.dart'; + +class AgoraPipControllerImpl extends AgoraPipController { + final RtcEngine _rtcEngine; + + AgoraPipControllerImpl(this._rtcEngine) { + throw UnimplementedError(); + } + + @override + Future dispose() async { + throw UnimplementedError(); + } + + @override + Future registerPipStateChangedObserver( + AgoraPipStateChangedObserver observer, + ) { + throw UnimplementedError(); + } + + @override + Future unregisterPipStateChangedObserver() { + throw UnimplementedError(); + } + + @override + Future pipIsSupported() { + throw UnimplementedError(); + } + + @override + Future pipIsAutoEnterSupported() { + throw UnimplementedError(); + } + + @override + Future isPipActivated() { + throw UnimplementedError(); + } + + @override + Future pipSetup(AgoraPipOptions options) { + throw UnimplementedError(); + } + + @override + Future pipStart() { + throw UnimplementedError(); + } + + @override + Future pipStop() { + throw UnimplementedError(); + } + + @override + Future pipDispose() { + throw UnimplementedError(); + } +} diff --git a/lib/src/impl/video_view_controller_impl.dart b/lib/src/impl/video_view_controller_impl.dart index 80313837f..7621798d2 100644 --- a/lib/src/impl/video_view_controller_impl.dart +++ b/lib/src/impl/video_view_controller_impl.dart @@ -203,158 +203,3 @@ mixin VideoViewControllerBaseMixin implements VideoViewControllerBase { @internal bool get shouldHandlerRenderMode => true; } - -/// Implementation of [PIPVideoViewController] -@Deprecated('This class is deprecated') -class PIPVideoViewControllerImpl extends VideoViewController - implements PIPVideoViewController { - /// @nodoc - @Deprecated('This constructor is deprecated') - // ignore: use_super_parameters - PIPVideoViewControllerImpl( - {required RtcEngine rtcEngine, - required VideoCanvas canvas, - bool useAndroidSurfaceView = false}) - : super( - rtcEngine: rtcEngine, - canvas: canvas, - useFlutterTexture: false, - useAndroidSurfaceView: useAndroidSurfaceView, - ); - - /// @nodoc - @Deprecated('This constructor is deprecated') - // ignore: use_super_parameters - PIPVideoViewControllerImpl.remote( - {required RtcEngine rtcEngine, - required VideoCanvas canvas, - required RtcConnection connection, - bool useAndroidSurfaceView = false}) - : super.remote( - rtcEngine: rtcEngine, - canvas: canvas, - connection: connection, - useFlutterTexture: false, - useAndroidSurfaceView: useAndroidSurfaceView, - ); - - int _nativeViewPtr = 0; - Completer _attachNativeViewCompleter = Completer(); - bool _isPipSetuped = false; - bool _isDisposedRender = false; - - @override - @Deprecated('This method is deprecated') - Future setupView(int platformViewId, int nativeViewPtr) async { - await super.setupView(platformViewId, nativeViewPtr); - - _isDisposedRender = false; - _nativeViewPtr = nativeViewPtr; - _attachNativeViewCompleter.complete(); - } - - @override - @Deprecated('This method is deprecated') - Future disposeRender() async { - _isDisposedRender = true; - _nativeViewPtr = 0; - _attachNativeViewCompleter = Completer(); - return super.disposeRender(); - } - - @override - @Deprecated('This method is deprecated') - Future dispose() async { - await stopPictureInPicture(); - await destroyPictureInPicture(); - } - - @override - @Deprecated('This method is deprecated') - Future isPipSupported() { - return rtcEngine.isPipSupported(); - } - - @override - @Deprecated('This method is deprecated') - Future destroyPictureInPicture() async { - // On android, there's no stop pip function - if (defaultTargetPlatform == TargetPlatform.iOS) { - await rtcEngine.setupPip(const PipOptions( - contentSource: 0, - contentWidth: 0, - contentHeight: 0, - autoEnterPip: false, - canvas: null)); - } - - _isPipSetuped = false; - } - - @override - @Deprecated('This method is deprecated') - Future setupPictureInPicture(PipOptions options) async { - assert(!kIsWeb, 'PIP feature is not supported on web.'); - assert( - defaultTargetPlatform == TargetPlatform.iOS || - defaultTargetPlatform == TargetPlatform.android, - 'PIP feature is not supported on this platform.'); - if (_isDisposedRender) { - return; - } - - late int contentSource = 0; - if (defaultTargetPlatform == TargetPlatform.iOS) { - if (_nativeViewPtr == 0) { - await _attachNativeViewCompleter.future; - } - contentSource = _nativeViewPtr; - } else { - assert(defaultTargetPlatform == TargetPlatform.android); - contentSource = - await rtcEngine.globalVideoViewController!.getCurrentActivityHandle(); - } - - if (contentSource == 0 || _isDisposedRender) { - return; - } - - final newVideoCanvasMap = (options.canvas ?? canvas).toJson(); - newVideoCanvasMap['view'] = contentSource; - VideoCanvas? newVideoCanvas = VideoCanvas.fromJson(newVideoCanvasMap); - PipOptions newOptions = PipOptions( - contentSource: contentSource, - contentWidth: options.contentWidth, - contentHeight: options.contentHeight, - autoEnterPip: options.autoEnterPip, - canvas: newVideoCanvas, - ); - - await rtcEngine.setupPip(newOptions); - - _isPipSetuped = true; - } - - @override - @Deprecated('This method is deprecated') - Future startPictureInPicture() async { - if (_isDisposedRender) { - return; - } - - await rtcEngine.startPip(); - } - - @override - @Deprecated('This method is deprecated') - Future stopPictureInPicture() async { - // On android, there's no stop pip function - if (defaultTargetPlatform == TargetPlatform.iOS) { - await rtcEngine.stopPip(); - } - } - - @override - @Deprecated('This getter is deprecated') - bool get isInPictureInPictureMode => _isPipSetuped; -} diff --git a/lib/src/render/video_view_controller.dart b/lib/src/render/video_view_controller.dart index b87cca3ed..d477dd1aa 100644 --- a/lib/src/render/video_view_controller.dart +++ b/lib/src/render/video_view_controller.dart @@ -111,57 +111,3 @@ class VideoViewController : VideoSourceType.videoSourceRemote.value(); } } - -/// @nodoc -@Deprecated('This class is deprecated') -abstract class PIPVideoViewController extends VideoViewControllerBase { - /// @nodoc - @Deprecated('This factory is deprecated') - factory PIPVideoViewController( - {required RtcEngine rtcEngine, - required VideoCanvas canvas, - bool useAndroidSurfaceView = false}) => - PIPVideoViewControllerImpl( - rtcEngine: rtcEngine, - canvas: canvas, - useAndroidSurfaceView: useAndroidSurfaceView, - ); - - /// @nodoc - @Deprecated('This factory is deprecated') - factory PIPVideoViewController.remote( - {required RtcEngine rtcEngine, - required VideoCanvas canvas, - required RtcConnection connection, - bool useAndroidSurfaceView = false}) => - PIPVideoViewControllerImpl.remote( - rtcEngine: rtcEngine, - canvas: canvas, - connection: connection, - useAndroidSurfaceView: useAndroidSurfaceView, - ); - - /// @nodoc - @Deprecated('This method is deprecated') - Future isPipSupported(); - - /// @nodoc - @Deprecated('This method is deprecated') - Future setupPictureInPicture(PipOptions options); - - /// @nodoc - @Deprecated('This method is deprecated') - Future destroyPictureInPicture(); - - /// @nodoc - @Deprecated('This method is deprecated') - Future startPictureInPicture(); - - /// @nodoc - @Deprecated('This method is deprecated') - Future stopPictureInPicture(); - - /// @nodoc - @Deprecated('This getter is deprecated') - bool get isInPictureInPictureMode; -} diff --git a/pubspec.yaml b/pubspec.yaml index 66cb9c5c1..7d3a56df8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: agora_rtc_engine description: >- Flutter plugin of Agora RTC SDK, allow you to simply integrate Agora Video Calling or Live Video Streaming to your app with just a few lines of code. -version: 6.3.2-sp.43211.b.7 +version: 6.3.2-sp.43211.b.8 homepage: https://www.agora.io repository: https://github.com/AgoraIO-Extensions/Agora-Flutter-SDK/tree/main environment: diff --git a/shared/darwin/AgoraUtils.h b/shared/darwin/AgoraUtils.h new file mode 100644 index 000000000..861b8bd89 --- /dev/null +++ b/shared/darwin/AgoraUtils.h @@ -0,0 +1,7 @@ +#ifdef DEBUG +#import + +#define AGORA_LOG(fmt, ...) NSLog((@"[AGORA_NG] " fmt), ##__VA_ARGS__) +#else +#define AGORA_LOG(fmt, ...) +#endif \ No newline at end of file