Picture in Picture
In the context of online conferencing increasingly developing, picture in picture is one of the should-have features of online meetings software.
Approach
- Based on Apple Docs, we will implement
AVPictureInPictureController
inViewController
. So we must create a newViewController
and replaceFlutterViewController
- Configure parameters for
AVPictureInPictureController
- Create a view that displays Video if the camera is turned on, and an view when the camera is turned off
- Get
VideoFrame
fromWebRTC
to display on the view we designed in the step above - Implement
MethodChannel
to execute fromFlutter
Apply in the code
Replace FlutterViewController
by new ViewController
- Create a new
ViewController
class WaterbusViewController: FlutterViewController {
// MARK: Singleton static let shared = WaterbusViewController()
// MARK: Public static variables static var pipController: AVPictureInPictureController? static var pipContentSource: Any? static var pipVideoCallViewController: Any?
// MARK: Private variables private var pictureInPictureView: PictureInPictureView = PictureInPictureView()
open override func viewDidLoad() { // get the flutter engine for the view let flutterEngine: FlutterEngine! = (UIApplication.shared.delegate as! AppDelegate).flutterEngine
// add flutter view addFlutterView(with: flutterEngine)
// configuration pip view controller preparePictureInPicture() }
func preparePictureInPicture() { if #available(iOS 15.0, *) { WaterbusViewController.pipVideoCallViewController = AVPictureInPictureVideoCallViewController() (WaterbusViewController.pipVideoCallViewController as! AVPictureInPictureVideoCallViewController).preferredContentSize = CGSize(width: Sizer.WIDTH_OF_PIP, height: Sizer.HEIGHT_OF_PIP) (WaterbusViewController.pipVideoCallViewController as! AVPictureInPictureVideoCallViewController).view.clipsToBounds = true
WaterbusViewController.pipContentSource = AVPictureInPictureController.ContentSource( activeVideoCallSourceView: self.view, contentViewController: (WaterbusViewController.pipVideoCallViewController as! AVPictureInPictureVideoCallViewController) ) } }
func configurationPictureInPicture(result: @escaping FlutterResult, peerConnectionId: String, remoteStreamId: String, isRemoteCameraEnable: Bool, myAvatar: String, remoteAvatar: String, remoteName: String) { if #available(iOS 15.0, *) { if (WaterbusViewController.pipContentSource != nil) { WaterbusViewController.pipController = AVPictureInPictureController(contentSource: WaterbusViewController.pipContentSource as! AVPictureInPictureController.ContentSource) WaterbusViewController.pipController?.canStartPictureInPictureAutomaticallyFromInline = true WaterbusViewController.pipController?.delegate = self
// Add view let frameOfPiP = (WaterbusViewController.pipVideoCallViewController as! AVPictureInPictureVideoCallViewController).view.frame pictureInPictureView = PictureInPictureView(frame: frameOfPiP) pictureInPictureView.contentMode = .scaleAspectFit pictureInPictureView.initParameters(peerConnectionId: peerConnectionId, remoteStreamId: remoteStreamId, isRemoteCameraEnable: isRemoteCameraEnable, myAvatar: myAvatar, remoteAvatar: remoteAvatar, remoteName: remoteName) (WaterbusViewController.pipVideoCallViewController as! AVPictureInPictureVideoCallViewController).view.addSubview(pictureInPictureView)
addConstraintLayout() } }
result(true) }
func addConstraintLayout() { if #available(iOS 15.0, *) { pictureInPictureView.translatesAutoresizingMaskIntoConstraints = false let constraints = [ pictureInPictureView.leadingAnchor.constraint(equalTo: (WaterbusViewController.pipVideoCallViewController as! AVPictureInPictureVideoCallViewController).view.leadingAnchor), pictureInPictureView.trailingAnchor.constraint(equalTo: (WaterbusViewController.pipVideoCallViewController as! AVPictureInPictureVideoCallViewController).view.trailingAnchor), pictureInPictureView.topAnchor.constraint(equalTo: (WaterbusViewController.pipVideoCallViewController as! AVPictureInPictureVideoCallViewController).view.topAnchor), pictureInPictureView.bottomAnchor.constraint(equalTo: (WaterbusViewController.pipVideoCallViewController as! AVPictureInPictureVideoCallViewController).view.bottomAnchor) ] (WaterbusViewController.pipVideoCallViewController as! AVPictureInPictureVideoCallViewController).view.addConstraints(constraints) pictureInPictureView.bounds = (WaterbusViewController.pipVideoCallViewController as! AVPictureInPictureVideoCallViewController).view.frame } }
func updatePictureInPictureView(_ result: @escaping FlutterResult, peerConnectionId: String, remoteStreamId: String, isRemoteCameraEnable: Bool, remoteAvatar: String, remoteName: String) { pictureInPictureView.setRemoteInfo(peerConnectionId: peerConnectionId, remoteStreamId: remoteStreamId, isRemoteCameraEnable: isRemoteCameraEnable, remoteAvatar: remoteAvatar, remoteName: remoteName) result(true) }
func updateStateUserView(_ result: @escaping FlutterResult, isRemoteCameraEnable: Bool) { pictureInPictureView.updateStateValue(isRemoteCameraEnable: isRemoteCameraEnable) result(true) }
func disposePictureInPicture() { // MARK: reset pictureInPictureView.disposeVideoView()
if #available(iOS 15.0, *) { (WaterbusViewController.pipVideoCallViewController as! AVPictureInPictureVideoCallViewController).view.removeAllSubviews() }
if (WaterbusViewController.pipController == nil) { return }
WaterbusViewController.pipController = nil }
func stopPictureInPicture() { if #available(iOS 15.0, *) { DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { WaterbusViewController.pipController?.stopPictureInPicture() } } }}
- Extensions
extension WaterbusViewController: AVPictureInPictureControllerDelegate { func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { print(">> pictureInPictureControllerWillStopPictureInPicture") self.pictureInPictureView.stopPictureInPictureView() }
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { print(">> pictureInPictureControllerWillStartPictureInPicture") self.pictureInPictureView.updateLayoutVideoVideo() }
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) { print("Unable start pip error:", error.localizedDescription) }}
// create an extension for all UIViewControllersextension UIViewController { /** Add a flutter sub view to the UIViewController sets constraints to edge to edge, covering all components on the screen */ func addFlutterView(with engine: FlutterEngine) { // create the flutter view controller let flutterViewController = FlutterViewController(engine: engine, nibName: nil, bundle: nil)
addChild(flutterViewController)
guard let flutterView = flutterViewController.view else { return }
// allows constraint manipulation flutterView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(flutterView)
// set the constraints (edge-to-edge) to the flutter view let constraints = [ flutterView.topAnchor.constraint(equalTo: view.topAnchor), flutterView.leadingAnchor.constraint(equalTo: view.leadingAnchor), flutterView.bottomAnchor.constraint(equalTo: view.bottomAnchor), flutterView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]
// apply (activate) the constraints NSLayoutConstraint.activate(constraints)
flutterViewController.didMove(toParent: self)
// updates the view with configured layout flutterView.layoutIfNeeded() }}
- Replace class in
Main.storyboard
byWaterbusViewController
:

Create a view that displays Video if the camera is turned on, and an view when the camera is turned off
class PictureInPictureView: UIView { // MARK: Private private var myUserNameCard: UserView = UserView() private var remoteUserNameCard: UserView = UserView() private var localView: UIView = UIView() private var remoteView: UIView = UIView() private var remoteRenderer: RTCMTLVideoView? private var peerConnectionId: String? private var remoteStreamId: String? private var isLocalCameraEnable: Bool = false private var isRemoteCameraEnable: Bool = false
private var pictureInPictureIsRunning: Bool = false
// MARK: Funcs override init(frame: CGRect) { super.init(frame: frame) setupView() }
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }}
Setup Child View
func setupView() { // MARK: Setup subviews localView = UIView() localView.clipsToBounds = true remoteView = UIView() remoteView.clipsToBounds = true
// MARK: add to parent view addSubview(localView) addSubview(remoteView) configurationLayoutConstrains()
// MARK: add user card view to subviews self.addAvatarView() self.configurationLayoutConstraintUserNameCard() }
func addAvatarView() { // Add local and remote avatar myUserNameCard = UserView() myUserNameCard.setUserName(userName: "You") myUserNameCard.contentMode = .scaleAspectFit localView.addSubview(myUserNameCard)
remoteUserNameCard = UserView() remoteUserNameCard.contentMode = .scaleAspectFit remoteView.addSubview(remoteUserNameCard) }
func setRemoteInfo(peerConnectionId: String, remoteStreamId: String, isRemoteCameraEnable: Bool, remoteAvatar: String, remoteName: String) { self.peerConnectionId = peerConnectionId self.remoteStreamId = remoteStreamId self.isRemoteCameraEnable = isRemoteCameraEnable self.remoteUserNameCard.setAvatar(avatar: remoteAvatar) self.remoteUserNameCard.setUserName(userName: remoteName) }
func configurationLayoutConstrains() { // Enable Autolayout localView.translatesAutoresizingMaskIntoConstraints = false remoteView.translatesAutoresizingMaskIntoConstraints = false
localView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor).isActive = true localView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor).isActive = true localView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.5).isActive = true localView.heightAnchor.constraint(equalTo: heightAnchor).isActive = true
remoteView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor).isActive = true remoteView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor).isActive = true remoteView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.5).isActive = true remoteView.heightAnchor.constraint(equalTo: heightAnchor).isActive = true }
func configurationLayoutConstraintForRenderer() { if (self.remoteRenderer == nil) { return }
self.remoteRenderer!.translatesAutoresizingMaskIntoConstraints = false let constraints = [ self.remoteRenderer!.leadingAnchor.constraint(equalTo: remoteView.leadingAnchor), self.remoteRenderer!.trailingAnchor.constraint(equalTo: remoteView.trailingAnchor), self.remoteRenderer!.topAnchor.constraint(equalTo: remoteView.topAnchor), self.remoteRenderer!.bottomAnchor.constraint(equalTo: remoteView.bottomAnchor) ] self.remoteView.addConstraints(constraints) self.remoteRenderer!.bounds = self.remoteView.frame }
func configurationLayoutConstraintUserNameCard() { myUserNameCard.translatesAutoresizingMaskIntoConstraints = false remoteUserNameCard.translatesAutoresizingMaskIntoConstraints = false
let constraintsLocal = [ self.myUserNameCard.leadingAnchor.constraint(equalTo: localView.leadingAnchor), self.myUserNameCard.trailingAnchor.constraint(equalTo: localView.trailingAnchor), self.myUserNameCard.topAnchor.constraint(equalTo: localView.topAnchor), self.myUserNameCard.bottomAnchor.constraint(equalTo: localView.bottomAnchor) ] let constraintsRemote = [ self.remoteUserNameCard.leadingAnchor.constraint(equalTo: remoteView.leadingAnchor), self.remoteUserNameCard.trailingAnchor.constraint(equalTo: remoteView.trailingAnchor), self.remoteUserNameCard.topAnchor.constraint(equalTo: remoteView.topAnchor), self.remoteUserNameCard.bottomAnchor.constraint(equalTo: remoteView.bottomAnchor) ] self.localView.addConstraints(constraintsLocal) self.remoteView.addConstraints(constraintsRemote) self.myUserNameCard.bounds = self.localView.frame self.remoteUserNameCard.bounds = self.remoteView.frame }
func configurationVideoView() { if (remoteStreamId == nil || peerConnectionId == nil) { return }
if #available(iOS 15.0, *) { // Remote if (self.isRemoteCameraEnable) { DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { self.addRemoteRendererToView() } } } }
Get VideoFrame from WebRTC to display on the view we designed in the step above
func addRemoteRendererToView() { self.remoteRenderer = RTCMTLVideoView() self.remoteRenderer!.contentMode = .scaleAspectFit self.remoteRenderer!.videoContentMode = .scaleAspectFill
// Get RemoteMTLVideoView let mediaRemoteStream = FlutterWebRTCPlugin.sharedSingleton().stream(forId: self.remoteStreamId, peerConnectionId: self.peerConnectionId) mediaRemoteStream?.videoTracks.first?.add(self.remoteRenderer!)
self.remoteView.addSubview(self.remoteRenderer!) self.configurationLayoutConstraintForRenderer() }
Implement MethodChannel to execute from Flutter
- iOS Side:
pictureInPictureChannel.setMethodCallHandler({ (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in switch (call.method) { case "startPictureInPicture": let arguments = call.arguments as? [String: Any] ?? [String: Any]() let remoteStreamId = arguments["remoteStreamId"] as? String ?? "" let peerConnectionId = arguments["peerConnectionId"] as? String ?? "" let isRemoteCameraEnable = arguments["isRemoteCameraEnable"] as? Bool ?? false let myAvatar = arguments["myAvatar"] as? String ?? "" let remoteAvatar = arguments["remoteAvatar"] as? String ?? "" let remoteName = arguments["remoteName"] as? String ?? ""
WaterbusViewController.shared.configurationPictureInPicture(result: result, peerConnectionId: peerConnectionId, remoteStreamId: remoteStreamId, isRemoteCameraEnable: isRemoteCameraEnable, myAvatar: myAvatar, remoteAvatar: remoteAvatar, remoteName: remoteName) case "updatePictureInPicture": let arguments = call.arguments as? [String: Any] ?? [String: Any]() let peerConnectionId = arguments["peerConnectionId"] as? String ?? "" let remoteStreamId = arguments["remoteStreamId"] as? String ?? "" let isRemoteCameraEnable = arguments["isRemoteCameraEnable"] as? Bool ?? false let remoteAvatar = arguments["remoteAvatar"] as? String ?? "" let remoteName = arguments["remoteName"] as? String ?? "" WaterbusViewController.shared.updatePictureInPictureView(result, peerConnectionId: peerConnectionId, remoteStreamId: remoteStreamId, isRemoteCameraEnable: isRemoteCameraEnable, remoteAvatar: remoteAvatar, remoteName: remoteName) case "updateState": let arguments = call.arguments as? [String: Any] ?? [String: Any]() let isRemoteCameraEnable = arguments["isRemoteCameraEnable"] as? Bool ?? false WaterbusViewController.shared.updateStateUserView(result, isRemoteCameraEnable: isRemoteCameraEnable) case "stopPictureInPicture": WaterbusViewController.shared.disposePictureInPicture() result(true) default: result(FlutterMethodNotImplemented) } })
Flutter Side
final MethodChannel _pipChannel = const MethodChannel("waterbus/picture-in-picture");
- Start PiP
Future<void> startPip({ required String remoteStreamId, required String peerConnectionId, required String myAvatar, required String remoteAvatar, required String remoteName, required bool isRemoteCameraEnable, }) async { if (!Platform.isIOS || DateTime.now().difference(_latestUpdate).inSeconds <= 2) return;
if (_isCreatedPip) { if (_currentRemote == remoteStreamId) { _pipChannel.invokeMethod("updateState", { "isRemoteCameraEnable": isRemoteCameraEnable, }); } else { _currentRemote = remoteStreamId; _pipChannel.invokeMethod("updatePictureInPicture", { "remoteStreamId": remoteStreamId, "peerConnectionId": peerConnectionId, "isRemoteCameraEnable": isRemoteCameraEnable, "remoteAvatar": remoteAvatar, "remoteName": remoteName, }); } } else { _currentRemote = remoteStreamId; _isCreatedPip = true; _pipChannel.invokeMethod("startPictureInPicture", { "remoteStreamId": remoteStreamId, "peerConnectionId": peerConnectionId, "isRemoteCameraEnable": isRemoteCameraEnable, "myAvatar": myAvatar, "remoteAvatar": remoteAvatar, "remoteName": remoteName, }); }
_latestUpdate = DateTime.now(); }
- Stop PiP
void stopPip() { if (!_isCreatedPip) return;
_isCreatedPip = false; _currentRemote = ''; _pipChannel.invokeMethod("stopPictureInPicture"); }