From 6f1ad6af19d0fda512b30c184a8d675c2c91b374 Mon Sep 17 00:00:00 2001 From: qinhui <> Date: Fri, 9 Aug 2024 22:14:34 +0800 Subject: [PATCH] feat: add pip example --- .../Base.lproj/PictureInPicture.storyboard | 95 ----- .../ChannelViewController.swift | 59 +++ .../CustomViewPIPService.swift | 169 +++++++++ .../CustomViewPIPViewController.swift | 234 ++++++++++++ .../PIPBaseViewController.swift | 12 + .../PictureInPicture.storyboard | 45 +++ .../PictureInPicture/PictureInPicture.swift | 347 ++---------------- .../PixelBufferPIPService.swift | 114 ++++++ .../PixelBufferPIPViewController.swift | 264 +++++++++++++ .../PixelBufferRenderView.swift | 179 +++++++++ .../zh-Hans.lproj/PictureInPicture.strings | 12 - iOS/APIExample/Podfile | 2 + 12 files changed, 1114 insertions(+), 418 deletions(-) delete mode 100644 iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/Base.lproj/PictureInPicture.storyboard create mode 100644 iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/ChannelViewController.swift create mode 100644 iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/CustomViewPIPViewController/CustomViewPIPService.swift create mode 100644 iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/CustomViewPIPViewController/CustomViewPIPViewController.swift create mode 100644 iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PIPBaseViewController.swift create mode 100644 iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PictureInPicture.storyboard create mode 100644 iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PixelBufferPIPViewController/PixelBufferPIPService.swift create mode 100644 iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PixelBufferPIPViewController/PixelBufferPIPViewController.swift create mode 100644 iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PixelBufferPIPViewController/PixelBufferRenderView.swift delete mode 100644 iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/zh-Hans.lproj/PictureInPicture.strings diff --git a/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/Base.lproj/PictureInPicture.storyboard b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/Base.lproj/PictureInPicture.storyboard deleted file mode 100644 index 84b72944c..000000000 --- a/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/Base.lproj/PictureInPicture.storyboard +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/ChannelViewController.swift b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/ChannelViewController.swift new file mode 100644 index 000000000..49e931b0c --- /dev/null +++ b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/ChannelViewController.swift @@ -0,0 +1,59 @@ +// +// ViewController.swift +// PIPDemo +// +// Created by qinhui on 2024/8/7. +// + +import UIKit + +class ChannelViewController: UIViewController { + lazy var textField: UITextField = { + let t = UITextField() + t.placeholder = "输入房间号" + t.borderStyle = .line + t.backgroundColor = .orange + return t + }() + + var pipCls: T.Type? + + lazy var button: UIButton = { + let b = UIButton(type: .custom) + b.setTitle("加入房间", for: .normal) + b.setTitleColor(.blue, for: .normal) + b.addTarget(self, action: #selector(joinAction), for: .touchUpInside) + return b + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + + view.addSubview(textField) + view.addSubview(button) + + button.snp.makeConstraints { make in + make.center.equalTo(view) + } + + textField.snp.makeConstraints { make in + make.bottom.equalTo(button.snp.top).offset(-50) + make.centerX.equalTo(button) + make.width.equalTo(150) + make.height.equalTo(30) + } + } + + @objc func joinAction() { + guard let channelId = textField.text, let cls = pipCls else { return } + + let vc = cls.init() + vc.channelId = channelId + self.navigationController?.pushViewController(vc, animated: true) + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + view.endEditing(true) + } +} diff --git a/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/CustomViewPIPViewController/CustomViewPIPService.swift b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/CustomViewPIPViewController/CustomViewPIPService.swift new file mode 100644 index 000000000..4e99795c7 --- /dev/null +++ b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/CustomViewPIPViewController/CustomViewPIPService.swift @@ -0,0 +1,169 @@ +// +// RtcManager.swift +// PIPDemo +// +// Created by qinhui on 2024/8/7. +// + +import Foundation +import AgoraRtcKit + +class CustomViewPIPService: NSObject { + var rtcEngineDelegate: AgoraRtcEngineDelegate? + var videoFrameDelegte: AgoraVideoFrameDelegate? + + weak var localView: UIView? + weak var remoteView: UIView? + var channelId: String + + private lazy var rtcConfig: AgoraRtcEngineConfig = { + let config = AgoraRtcEngineConfig() + config.appId = KeyCenter.AppId + config.areaCode = .global + config.channelProfile = .liveBroadcasting + return config + }() + + private lazy var rtcEngine: AgoraRtcEngineKit = { + let engine = AgoraRtcEngineKit.sharedEngine(with: rtcConfig, delegate: self) + engine.setClientRole(.broadcaster) + engine.enableAudio() + engine.enableVideo() + engine.setVideoEncoderConfiguration(AgoraVideoEncoderConfiguration(size: CGSize(width: 960, height: 540), + frameRate: .fps15, + bitrate: AgoraVideoBitrateStandard, + orientationMode: .adaptative, + mirrorMode: .auto)) + engine.setVideoFrameDelegate(self) + return engine + }() + + init(localView: UIView, remoteView: UIView, channelId: String) { + self.localView = localView + self.remoteView = remoteView + self.channelId = channelId + + super.init() + + setupRtcEngin() + } + + private func setupRtcEngin() { + let videoCanvas = AgoraRtcVideoCanvas() + videoCanvas.uid = 0 + videoCanvas.view = localView + videoCanvas.renderMode = .hidden + + rtcEngine.setupLocalVideo(videoCanvas) + rtcEngine.startPreview() + rtcEngine.setDefaultAudioRouteToSpeakerphone(true) + rtcEngine.setVideoFrameDelegate(self) + + let option = AgoraRtcChannelMediaOptions() + option.publishCameraTrack = true + option.publishMicrophoneTrack = true + option.clientRoleType = .broadcaster + + NetworkManager.shared.generateToken(channelName: channelId, success: { [weak self] token in + guard let self = self else { return } + + let result = self.rtcEngine.joinChannel(byToken: token, channelId: self.channelId, uid: 0, mediaOptions: option) + if result != 0 { + ToastView.showWait(text: "joinChannel call failed: \(result), please check your params", view: nil) + } + }) + } + + func disable() { + rtcEngine.disableAudio() + rtcEngine.disableVideo() + } + + func leave() { + rtcEngine.stopPreview() + rtcEngine.leaveChannel(nil) + } + +} + +extension CustomViewPIPService: AgoraRtcEngineDelegate { + func rtcEngine(_ engine: AgoraRtcEngineKit, didOccur errorType: AgoraEncryptionErrorType) { + rtcEngineDelegate?.rtcEngine?(engine, didOccur: errorType) + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, withUid uid: UInt, elapsed: Int) { + rtcEngineDelegate?.rtcEngine?(engine, didJoinChannel: channel, withUid: uid, elapsed: elapsed) + } + + /// callback when a remote user is joinning the channel, note audience in live broadcast mode will NOT trigger this event + /// @param uid uid of remote joined user + /// @param elapsed time elapse since current sdk instance join the channel in ms + func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) { + // Only one remote video view is available for this + // tutorial. Here we check if there exists a surface + // view tagged as this uid. + let videoCanvas = AgoraRtcVideoCanvas() + videoCanvas.uid = uid + // the view to be binded + videoCanvas.view = remoteView + videoCanvas.renderMode = .hidden + rtcEngine.setupRemoteVideo(videoCanvas) + + rtcEngineDelegate?.rtcEngine?(engine, didJoinedOfUid: uid, elapsed: elapsed) + } + + /// callback when a remote user is leaving the channel, note audience in live broadcast mode will NOT trigger this event + /// @param uid uid of remote joined user + /// @param reason reason why this user left, note this event may be triggered when the remote user + /// become an audience in live broadcasting profile + func rtcEngine(_ engine: AgoraRtcEngineKit, didOfflineOfUid uid: UInt, reason: AgoraUserOfflineReason) { + let videoCanvas = AgoraRtcVideoCanvas() + videoCanvas.uid = uid + // the view to be binded + videoCanvas.view = nil + videoCanvas.renderMode = .hidden + rtcEngine.setupRemoteVideo(videoCanvas) + + rtcEngineDelegate?.rtcEngine?(engine, didOfflineOfUid: uid, reason: reason) + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, connectionChangedTo state: AgoraConnectionState, reason: AgoraConnectionChangedReason) { + rtcEngineDelegate?.rtcEngine?(engine, connectionChangedTo: state, reason: reason) + } + + /// Reports the statistics of the current call. The SDK triggers this callback once every two seconds after the user joins the channel. + /// @param stats stats struct + func rtcEngine(_ engine: AgoraRtcEngineKit, reportRtcStats stats: AgoraChannelStats) { + rtcEngineDelegate?.rtcEngine?(engine, reportRtcStats: stats) + } + + /// Reports the statistics of the uploading local audio streams once every two seconds. + /// @param stats stats struct + func rtcEngine(_ engine: AgoraRtcEngineKit, localAudioStats stats: AgoraRtcLocalAudioStats) { + rtcEngineDelegate?.rtcEngine?(engine, localAudioStats: stats) + } + + /// Reports the statistics of the video stream from each remote user/host. + /// @param stats stats struct + func rtcEngine(_ engine: AgoraRtcEngineKit, remoteVideoStats stats: AgoraRtcRemoteVideoStats) { + rtcEngineDelegate?.rtcEngine?(engine, remoteVideoStats: stats) + } + + /// Reports the statistics of the audio stream from each remote user/host. + /// @param stats stats struct for current call statistics + func rtcEngine(_ engine: AgoraRtcEngineKit, remoteAudioStats stats: AgoraRtcRemoteAudioStats) { + rtcEngineDelegate?.rtcEngine?(engine, remoteAudioStats: stats) + } +} + +extension CustomViewPIPService: AgoraVideoFrameDelegate { + func onCapture(_ videoFrame: AgoraOutputVideoFrame, sourceType: AgoraVideoSourceType) -> Bool { + print("") + return true + } + + func onRenderVideoFrame(_ videoFrame: AgoraOutputVideoFrame, uid: UInt, channelId: String) -> Bool { + print("") + return true + } +} diff --git a/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/CustomViewPIPViewController/CustomViewPIPViewController.swift b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/CustomViewPIPViewController/CustomViewPIPViewController.swift new file mode 100644 index 000000000..ebe0ac770 --- /dev/null +++ b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/CustomViewPIPViewController/CustomViewPIPViewController.swift @@ -0,0 +1,234 @@ +// +// VideoViewController.swift +// PIPDemo +// +// Created by qinhui on 2024/8/7. +// + +import UIKit +import SnapKit +import AgoraRtcKit +import AVKit + +@available(iOS 15.0, *) +class CustomViewPIPViewController: PIPBaseViewController { + private let containerH = 250.0 + private var isJoined: Bool = false + private var pipController: AVPictureInPictureController? + private var videoCallbackController: AVPictureInPictureVideoCallViewController? + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + + private var pipSizes = [ + CGSize(width: 150, height: 300), + CGSize(width: 300, height: 150) + ] + + private lazy var pipButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("画中画", for: .normal) + button.addTarget(self, action: #selector(pipAction), for: .touchUpInside) + button.backgroundColor = .purple + return button + }() + + private lazy var sizeButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("切换尺寸", for: .normal) + button.addTarget(self, action: #selector(sizeAction), for: .touchUpInside) + button.backgroundColor = .red + + return button + }() + + private lazy var localVideoView: UIView = { + let view = UIView() + view.backgroundColor = .green + return view + }() + + private lazy var remoteVideoView: UIView = { + let view = UIView() + view.backgroundColor = .orange + return view + }() + + private lazy var videoContainerView: UIView = { + let view = UIView() + view.backgroundColor = .purple + return view + }() + + private var rtcService: CustomViewPIPService! + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + initRtc() + configViews() + if AVPictureInPictureController.isPictureInPictureSupported() { + configPIPViewController() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + guard let pipController = pipController else { return } + pipController.stopPictureInPicture() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + rtcService.disable() + + if isJoined { + rtcService.leave() + } + } +} + +@available(iOS 15.0, *) +extension CustomViewPIPViewController { + @objc func pipAction() { + guard let pipController = pipController else { return } + + if pipController.isPictureInPictureActive { + pipController.stopPictureInPicture() + } else { + pipController.startPictureInPicture() + } + } + + @objc func sizeAction() { + guard let videoCallbackController = videoCallbackController else { return } + + let i = Int.random(in: 0.. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PictureInPicture.swift b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PictureInPicture.swift index 01c87c5ea..f686853bb 100644 --- a/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PictureInPicture.swift +++ b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PictureInPicture.swift @@ -1,334 +1,59 @@ // -// PictureInPicture.swift -// APIExample +// HomeViewController.swift +// PIPDemo // -// Created by 胡润辰 on 2022/4/6. -// Copyright © 2022 Agora Corp. All rights reserved. +// Created by qinhui on 2024/8/8. // import UIKit -import AGEVideoLayout -import AgoraRtcKit -import MediaPlayer -class PictureInPictureEntry: UIViewController { - @IBOutlet weak var joinButton: AGButton! - @IBOutlet weak var channelTextField: AGTextField! - let identifier = "PictureInPicture" - - override func viewDidLoad() { - super.viewDidLoad() - } - - @IBAction func doJoinPressed(sender: AGButton) { - guard let channelName = channelTextField.text else {return} - // resign channel text field - channelTextField.resignFirstResponder() - - let storyBoard: UIStoryboard = UIStoryboard(name: identifier, bundle: nil) - // create new view controller every time to ensure we get a clean vc - guard let newViewController = storyBoard.instantiateViewController(withIdentifier: identifier) as? BaseViewController else { - return - } - newViewController.title = channelName - newViewController.configs = ["channelName": channelName] - navigationController?.pushViewController(newViewController, animated: true) +class Model { + var title: String + var cls: T.Type + init(title: String, cls: T.Type) { + self.title = title + self.cls = cls } } -@available(iOS 15.0, *) -class PictureInPictureMain: BaseViewController { - var localVideo = Bundle.loadVideoView(type: .local, audioOnly: false) - var remoteVideo = Bundle.loadView(fromNib: "VideoViewSampleBufferDisplayView", withType: SampleBufferDisplayView.self) - var agoraKit: AgoraRtcEngineKit! - private lazy var callViewController: AVPictureInPictureVideoCallViewController = { - let callViewController = AVPictureInPictureVideoCallViewController() - callViewController.preferredContentSize = view.bounds.size - callViewController.view.backgroundColor = .clear - callViewController.modalPresentationStyle = .overFullScreen - return callViewController - }() - var pipController: AVPictureInPictureController? - var remoteUid: UInt? - // indicate if current instance has joined channel - var isJoined: Bool = false - private lazy var containerView: UIView = { - let view = UIView() - view.backgroundColor = .red - return view +class PictureInPicture: UITableViewController { + lazy var dataArray: [Model] = { + if #available(iOS 15.0, *) { + return [ + Model(title: "SDK 渲染", cls: CustomViewPIPViewController.self), + Model(title: "多人视频自渲染", cls: PixelBufferPIPViewController.self) + ] + } else { + // Fallback on earlier versions + return [] + } }() - // swiftlint: disable function_body_length override func viewDidLoad() { super.viewDidLoad() - // layout render view - localVideo.setPlaceholder(text: "Local Host".localized) - remoteVideo.setPlaceholder(text: "Remote Host".localized) - view.addSubview(containerView) - containerView.frame = CGRect(x: 0, y: 0, width: SCREENSIZE.width, height: 280) - containerView.addSubview(localVideo) - containerView.addSubview(remoteVideo) - localVideo.translatesAutoresizingMaskIntoConstraints = false - remoteVideo.translatesAutoresizingMaskIntoConstraints = false - localVideo.leadingAnchor.constraint(equalTo: containerView.leadingAnchor).isActive = true - localVideo.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true - localVideo.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true - localVideo.widthAnchor.constraint(equalTo: containerView.widthAnchor, multiplier: 0.5).isActive = true - remoteVideo.trailingAnchor.constraint(equalTo: containerView.trailingAnchor).isActive = true - remoteVideo.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true - remoteVideo.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true - remoteVideo.widthAnchor.constraint(equalTo: containerView.widthAnchor, multiplier: 0.5).isActive = true - - pipController = AVPictureInPictureController(contentSource: .init(activeVideoCallSourceView: containerView, - contentViewController: callViewController)) - pipController?.canStartPictureInPictureAutomaticallyFromInline = true - pipController?.delegate = self - //iOS 15 workaround - pipController?.setValue(1, forKey: "controlsStyle") - - // set up agora instance when view loadedlet config = AgoraRtcEngineConfig() - let config = AgoraRtcEngineConfig() - config.appId = KeyCenter.AppId - config.channelProfile = .liveBroadcasting - config.areaCode = GlobalSettings.shared.area - // setup log file path - let logConfig = AgoraLogConfig() - logConfig.level = .info - config.logConfig = logConfig - agoraKit = AgoraRtcEngineKit.sharedEngine(with: config, delegate: self) - // Configuring Privatization Parameters - Util.configPrivatization(agoraKit: agoraKit) - // get channel name from configs - guard let channelName = configs["channelName"] as? String, - let resolution = GlobalSettings.shared.getSetting(key: "resolution")?.selectedOption().value as? CGSize, - let fps = GlobalSettings.shared.getSetting(key: "fps")?.selectedOption().value as? AgoraVideoFrameRate, - let orientation = GlobalSettings.shared.getSetting(key: "orientation")? - .selectedOption().value as? AgoraVideoOutputOrientationMode else { - return - } - // To enable MPNowPlayingInfoCenter, you need to add the following two private parameters - agoraKit.setParameters("{\"adm_mix_with_others\":false}") - agoraKit.setParameters("{\"che.audio.nonmixable.option\":true}") - - // make myself a broadcaster - agoraKit.setChannelProfile(.liveBroadcasting) - agoraKit.setClientRole(GlobalSettings.shared.getUserRole()) - - // enable video module and set up video encoding configs - agoraKit.enableVideo() - agoraKit.setVideoEncoderConfiguration(AgoraVideoEncoderConfiguration(size: resolution, - frameRate: fps, - bitrate: AgoraVideoBitrateStandard, - orientationMode: orientation, - mirrorMode: AgoraVideoMirrorMode.auto)) - // set up local video to render your local camera preview - let videoCanvas = AgoraRtcVideoCanvas() - videoCanvas.uid = 0 - // the view to be binded - videoCanvas.view = localVideo.videoView - videoCanvas.renderMode = .hidden - agoraKit.setupLocalVideo(videoCanvas) - - // Set audio route to speaker - agoraKit.setDefaultAudioRouteToSpeakerphone(true) - - // Setup raw video data frame observer - agoraKit.setVideoFrameDelegate(self) - - // start joining channel - // 1. Users can only see each other after they join the - // same channel successfully using the same app id. - // 2. If app certificate is turned on at dashboard, token is needed - // when joining channel. The channel name and uid used to calculate - // the token has to match the ones used for channel join - let option = AgoraRtcChannelMediaOptions() - NetworkManager.shared.generateToken(channelName: channelName, success: { token in - let result = self.agoraKit.joinChannel(byToken: token, channelId: channelName, uid: 0, mediaOptions: option, joinSuccess: nil) - if result != 0 { - // Usually happens with invalid parameters - // Error code description can be found at: - // en: https://api-ref.agora.io/en/video-sdk/ios/4.x/documentation/agorartckit/agoraerrorcode - // cn: https://doc.shengwang.cn/api-ref/rtc/ios/error-code - self.showAlert(title: "Error", message: "joinChannel call failed: \(result), please check your params") - } - }) - - NotificationCenter.default.addObserver(self, - selector: #selector(didEnterBackgroundNotification), - name: UIApplication.willResignActiveNotification, - object: nil) + title = "Picture In Picture" + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") } - // swiftlint: enable function_body_length - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - setupPlayintInfoCenter() - } - - private func setupPlayintInfoCenter() { - UIApplication.shared.beginReceivingRemoteControlEvents() - var nowPlayingInfo = [String: Any]() - let path = Bundle.main.path(forResource: "agora-logo", ofType: "png") ?? "" - guard let image = UIImage(contentsOfFile: path) else { return } - let artWork = MPMediaItemArtwork(boundsSize: image.size) { _ in - return image - } - nowPlayingInfo[MPMediaItemPropertyArtwork] = artWork - nowPlayingInfo[MPMediaItemPropertyTitle] = "Song Title" - nowPlayingInfo[MPMediaItemPropertyArtist] = "Artist Name" - nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = "Album Name" - nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = true - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - MPNowPlayingInfoCenter.default().playbackState = .playing + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 50 } - override var canBecomeFirstResponder: Bool { - true + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + dataArray.count } - deinit { - NotificationCenter.default.removeObserver(self) + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let model = dataArray[indexPath.row] + let vc = ChannelViewController() + vc.pipCls = model.cls + self.navigationController?.pushViewController(vc, animated: true) } - @objc - private func didEnterBackgroundNotification() { - onPIP(_btn: UIButton()) - } - - @IBAction func onPIP(_btn: UIButton) { - if let currentPipController = pipController { - currentPipController.startPictureInPicture() - } else { - showAlert(message: "PIP Support iOS 15+".localized) - } - } - - override func willMove(toParent parent: UIViewController?) { - if parent == nil { - // leave channel when exiting the view - if isJoined { - if let pipController = pipController, pipController.isPictureInPictureActive { - pipController.stopPictureInPicture() - } - agoraKit.leaveChannel { (stats) -> Void in - LogUtils.log(message: "left channel, duration: \(stats.duration)", level: .info) - } - } - } - } -} - -/// agora rtc engine delegate events -@available(iOS 15.0, *) -extension PictureInPictureMain: AgoraRtcEngineDelegate { - /// callback when warning occured for agora sdk, warning can usually be ignored, still it's nice to check out - /// what is happening - /// Warning code description can be found at: - /// en: https://api-ref.agora.io/en/voice-sdk/ios/3.x/Constants/AgoraWarningCode.html - /// cn: https://docs.agora.io/cn/Voice/API%20Reference/oc/Constants/AgoraWarningCode.html - /// @param warningCode warning code of the problem - func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurWarning warningCode: AgoraWarningCode) { - LogUtils.log(message: "warning: \(warningCode.description)", level: .warning) - } - - /// callback when error occured for agora sdk, you are recommended to display the error descriptions on demand - /// to let user know something wrong is happening - /// Error code description can be found at: - /// en: https://api-ref.agora.io/en/video-sdk/ios/4.x/documentation/agorartckit/agoraerrorcode - /// cn: https://doc.shengwang.cn/api-ref/rtc/ios/error-code - /// @param errorCode error code of the problem - func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) { - LogUtils.log(message: "error: \(errorCode)", level: .error) - self.showAlert(title: "Error", message: "Error \(errorCode.description) occur") - } - - /// callback when the local user joins a specified channel. - /// @param channel - /// @param uid uid of local user - /// @param elapsed time elapse since current sdk instance join the channel in ms - func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, withUid uid: UInt, elapsed: Int) { - isJoined = true - LogUtils.log(message: "Join \(channel) with uid \(uid) elapsed \(elapsed)ms", level: .info) - } - - /// callback when a remote user is joinning the channel, note audience in live broadcast mode will NOT trigger this event - /// @param uid uid of remote joined user - /// @param elapsed time elapse since current sdk instance join the channel in ms - func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) { - LogUtils.log(message: "remote user join: \(uid) \(elapsed)ms", level: .info) - - remoteVideo.videoView.reset() - } - - /// callback when a remote user is leaving the channel, note audience in live broadcast mode will NOT trigger this event - /// @param uid uid of remote joined user - /// @param reason reason why this user left, note this event may be triggered when the remote user - /// become an audience in live broadcasting profile - func rtcEngine(_ engine: AgoraRtcEngineKit, didOfflineOfUid uid: UInt, reason: AgoraUserOfflineReason) { - LogUtils.log(message: "remote user left: \(uid) reason \(reason)", level: .info) - - // to unlink your view from sdk, so that your view reference will be released - // note the video will stay at its last frame, to completely remove it - // you will need to remove the EAGL sublayer from your binded view -// remoteVideo.videoView.reset() - } - func rtcEngine(_ engine: AgoraRtcEngineKit, - remoteVideoStateChangedOfUid uid: UInt, - state: AgoraVideoRemoteState, reason: AgoraVideoRemoteReason, - elapsed: Int) { - if reason == .remoteMuted { - let pixelBuffer = MediaUtils.cvPixelBufferRef(from: UIImage(named: "agora-logo") ?? UIImage()).takeRetainedValue() - let videoFrame = AgoraOutputVideoFrame() - videoFrame.pixelBuffer = pixelBuffer - videoFrame.width = Int32(remoteVideo.videoView.frame.width) - videoFrame.height = Int32(remoteVideo.videoView.frame.height) - remoteVideo.videoView.renderVideoPixelBuffer(videoFrame) - } - } -} - -// MARK: - AgoraVideoDataFrameProtocol -@available(iOS 15.0, *) -extension PictureInPictureMain: AgoraVideoFrameDelegate { - func onCapture(_ videoFrame: AgoraOutputVideoFrame, sourceType: AgoraVideoSourceType) -> Bool { - true - } - - func onRenderVideoFrame(_ videoFrame: AgoraOutputVideoFrame, uid: UInt, channelId: String) -> Bool { - remoteVideo.videoView.renderVideoPixelBuffer(videoFrame) - return true - } - - func getVideoFormatPreference() -> AgoraVideoFormat { - .cvPixelBGRA - } - func getRotationApplied() -> Bool { - true - } -} - -@available(iOS 15.0, *) -extension PictureInPictureMain: AVPictureInPictureControllerDelegate { - func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { - } - - func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { - containerView.removeFromSuperview() - let vc = pictureInPictureController.contentSource?.activeVideoCallContentViewController - containerView.frame.size = vc?.view.bounds.size ?? .zero - vc?.view.addSubview(containerView) - } - - func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, - failedToStartPictureInPictureWithError error: Error) { - } - - func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { - containerView.removeFromSuperview() - containerView.frame.size = CGSize(width: SCREENSIZE.width, height: 280) - view.addSubview(containerView) - } - - func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) + let model = dataArray[indexPath.row] + cell.textLabel?.text = model.title + return cell } } diff --git a/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PixelBufferPIPViewController/PixelBufferPIPService.swift b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PixelBufferPIPViewController/PixelBufferPIPService.swift new file mode 100644 index 000000000..d34047993 --- /dev/null +++ b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PixelBufferPIPViewController/PixelBufferPIPService.swift @@ -0,0 +1,114 @@ +// +// PixelBufferPIPService.swift +// PIPDemo +// +// Created by qinhui on 2024/8/8. +// + +import Foundation +import AgoraRtcKit + +class PixelBufferPIPService: NSObject { + var videoFrameDelegte: AgoraVideoFrameDelegate? + var rtcEngineDelegate: AgoraRtcEngineDelegate? + weak var localView: PixelBufferRenderView? + + var uid: UInt = 0 + var channelId: String + + private lazy var rtcConfig: AgoraRtcEngineConfig = { + let config = AgoraRtcEngineConfig() + config.appId = KeyCenter.AppId + config.areaCode = .global + config.channelProfile = .liveBroadcasting + return config + }() + + private lazy var rtcEngine: AgoraRtcEngineKit = { + let engine = AgoraRtcEngineKit.sharedEngine(with: rtcConfig, delegate: self) + engine.setClientRole(.broadcaster) + engine.enableAudio() + engine.enableVideo() + engine.setVideoEncoderConfiguration(AgoraVideoEncoderConfiguration(size: CGSize(width: 960, height: 540), + frameRate: .fps15, + bitrate: AgoraVideoBitrateStandard, + orientationMode: .fixedPortrait, + mirrorMode: .auto)) + engine.setVideoFrameDelegate(self) + + return engine + }() + + init(channelId: String, uid: UInt, localView: PixelBufferRenderView) { + self.channelId = channelId + self.uid = uid + self.localView = localView + super.init() + + setupRtcEngin() + } + + private func setupRtcEngin() { + let videoCanvas = AgoraRtcVideoCanvas() + videoCanvas.uid = 0 + videoCanvas.view = localView + videoCanvas.renderMode = .hidden + + rtcEngine.setupLocalVideo(videoCanvas) + rtcEngine.startPreview() + + rtcEngine.setDefaultAudioRouteToSpeakerphone(true) + rtcEngine.setVideoFrameDelegate(self) + + let option = AgoraRtcChannelMediaOptions() + option.publishCameraTrack = true + option.publishMicrophoneTrack = true + option.clientRoleType = .broadcaster + + NetworkManager.shared.generateToken(channelName: channelId, success: { [weak self] token in + guard let self = self else { return } + let result = self.rtcEngine.joinChannel(byToken: token, channelId: self.channelId, uid: self.uid, mediaOptions: option) + if result != 0 { + ToastView.showWait(text: "joinChannel call failed: \(result), please check your params", view: nil) + } else { + self.localView?.uid = self.uid + } + }) + } + + func disable() { + rtcEngine.disableAudio() + rtcEngine.disableVideo() + } + + func leave() { + rtcEngine.stopPreview() + rtcEngine.leaveChannel(nil) + } + +} + +extension PixelBufferPIPService: AgoraVideoFrameDelegate { + func onCapture(_ videoFrame: AgoraOutputVideoFrame, sourceType: AgoraVideoSourceType) -> Bool { + return ((self.videoFrameDelegte?.onCapture?(videoFrame, sourceType: sourceType)) != nil) + } + + func onRenderVideoFrame(_ videoFrame: AgoraOutputVideoFrame, uid: UInt, channelId: String) -> Bool { + return ((self.videoFrameDelegte?.onRenderVideoFrame?(videoFrame, uid: uid, channelId: channelId)) != nil) + } +} + +extension PixelBufferPIPService: AgoraRtcEngineDelegate { + func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, withUid uid: UInt, elapsed: Int) { + rtcEngineDelegate?.rtcEngine?(engine, didJoinChannel: channel, withUid: uid, elapsed: elapsed) + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) { + rtcEngineDelegate?.rtcEngine?(engine, didJoinedOfUid: uid, elapsed: elapsed) + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didOfflineOfUid uid: UInt, reason: AgoraUserOfflineReason) { + rtcEngineDelegate?.rtcEngine?(engine, didOfflineOfUid: uid, reason: reason) + } + +} diff --git a/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PixelBufferPIPViewController/PixelBufferPIPViewController.swift b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PixelBufferPIPViewController/PixelBufferPIPViewController.swift new file mode 100644 index 000000000..9378fb7bc --- /dev/null +++ b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PixelBufferPIPViewController/PixelBufferPIPViewController.swift @@ -0,0 +1,264 @@ +// +// PixelBufferPIPViewController.swift +// PIPDemo +// +// Created by qinhui on 2024/8/8. +// +import UIKit +import AVKit +import AgoraRtcKit + +@available(iOS 15.0, *) +class PixelBufferPIPViewController: PIPBaseViewController { + private let mockUid: UInt = UInt.random(in: 0...100000) + private var pipController: AVPictureInPictureController? + private var videoCallbackController: AVPictureInPictureVideoCallViewController? + var isJoined = false + private var pipSizes = [ + CGSize(width: 150, height: 300), + CGSize(width: 300, height: 150) + ] + + private lazy var pipButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("画中画", for: .normal) + button.setTitleColor(.black, for: .normal) + button.addTarget(self, action: #selector(pipAction), for: .touchUpInside) + + return button + }() + + private lazy var sizeButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitle("切换尺寸", for: .normal) + button.setTitleColor(.black, for: .normal) + button.addTarget(self, action: #selector(sizeAction), for: .touchUpInside) + + return button + }() + + private lazy var topLeftView: PixelBufferRenderView = { + let view = PixelBufferRenderView() + view.backgroundColor = .blue + return view + }() + + private lazy var topRightView: PixelBufferRenderView = { + let view = PixelBufferRenderView() + view.backgroundColor = .red + return view + }() + + private lazy var bottomLeftView: PixelBufferRenderView = { + let view = PixelBufferRenderView() + view.backgroundColor = .green + return view + }() + + private lazy var bottomRightView: PixelBufferRenderView = { + let view = PixelBufferRenderView() + view.backgroundColor = .purple + return view + }() + + private lazy var videoContainerView: UIView = { + let view = UIView() + view.backgroundColor = .purple + return view + }() + + private lazy var displayViews: NSHashTable = { + let table = NSHashTable(options: .weakMemory, capacity: 4) + table.add(self.topLeftView) + table.add(self.topRightView) + table.add(bottomLeftView) + table.add(bottomRightView) + return table + }() + + private var rtcService: PixelBufferPIPService! + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + initRtc() + configViews() + if AVPictureInPictureController.isPictureInPictureSupported() { + configPIPViewController() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + guard let pipController = pipController else { return } + pipController.stopPictureInPicture() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + rtcService.disable() + + if isJoined { + rtcService.leave() + } + } +} + +@available(iOS 15.0, *) +extension PixelBufferPIPViewController { + private func configPIPViewController() { + let videoCallViewController = AVPictureInPictureVideoCallViewController() + videoCallViewController.preferredContentSize = view.bounds.size + videoCallViewController.view.backgroundColor = .clear + videoCallViewController.modalPresentationStyle = .overFullScreen + + self.videoCallbackController = videoCallViewController + pipController = AVPictureInPictureController(contentSource: .init(activeVideoCallSourceView: videoContainerView, + contentViewController: videoCallViewController)) + pipController?.canStartPictureInPictureAutomaticallyFromInline = true + pipController?.delegate = self + pipController?.setValue(1, forKey: "controlsStyle") + } + + private func configViews() { + self.view.addSubview(videoContainerView) + videoContainerView.addSubview(topLeftView) + videoContainerView.addSubview(topRightView) + videoContainerView.addSubview(bottomLeftView) + videoContainerView.addSubview(bottomRightView) + + self.view.addSubview(pipButton) + self.view.addSubview(sizeButton) + + videoContainerView.snp.makeConstraints { make in + make.left.top.right.bottom.equalTo(0) + } + + topLeftView.snp.makeConstraints { make in + make.top.left.equalToSuperview() + make.width.equalToSuperview().dividedBy(2) + make.height.equalToSuperview().dividedBy(2) + } + + topRightView.snp.makeConstraints { make in + make.top.equalToSuperview() + make.right.equalToSuperview() + make.width.equalToSuperview().dividedBy(2) + make.height.equalToSuperview().dividedBy(2) + } + + bottomLeftView.snp.makeConstraints { make in + make.bottom.equalToSuperview() + make.left.equalToSuperview() + make.width.equalToSuperview().dividedBy(2) + make.height.equalToSuperview().dividedBy(2) + } + + bottomRightView.snp.makeConstraints { make in + make.bottom.equalToSuperview() + make.right.equalToSuperview() + make.width.equalToSuperview().dividedBy(2) + make.height.equalToSuperview().dividedBy(2) + } + + pipButton.snp.makeConstraints { make in + make.center.equalTo(view) + } + + sizeButton.snp.makeConstraints { make in + make.top.equalTo(self.pipButton.snp.bottom).offset(10) + make.centerX.equalTo(self.pipButton.snp.centerX) + } + } + + private func initRtc() { + guard let channelId = channelId else { return } + rtcService = PixelBufferPIPService(channelId: channelId, uid: mockUid, localView: topLeftView) + rtcService.videoFrameDelegte = self + rtcService.rtcEngineDelegate = self + } + + @objc func pipAction() { + guard let pipController = pipController else { return } + + if pipController.isPictureInPictureActive { + pipController.stopPictureInPicture() + } else { + pipController.startPictureInPicture() + } + } + + @objc func sizeAction() { + guard let videoCallbackController = videoCallbackController else { return } + + let i = Int.random(in: 0.. Bool { + if let view = displayViews.allObjects.first(where: { $0.uid == mockUid }), let pixelBuffer = videoFrame.pixelBuffer { + view.renderVideoPixelBuffer(pixelBuffer: pixelBuffer, width: videoFrame.width, height: videoFrame.height) + } + + return true + } + + func onRenderVideoFrame(_ videoFrame: AgoraOutputVideoFrame, uid: UInt, channelId: String) -> Bool { + if let view = displayViews.allObjects.first(where: { $0.uid == uid }) { + view.renderFromVideoFrameData(videoData: videoFrame, uid: Int(uid)) + } + + return true + } +} diff --git a/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PixelBufferPIPViewController/PixelBufferRenderView.swift b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PixelBufferPIPViewController/PixelBufferRenderView.swift new file mode 100644 index 000000000..330b3f9cb --- /dev/null +++ b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/PixelBufferPIPViewController/PixelBufferRenderView.swift @@ -0,0 +1,179 @@ +// +// PixelBufferRenderView.swift +// PIPDemo +// +// Created by qinhui on 2024/8/8. +// + +import UIKit +import AVFoundation +import AgoraRtcKit + +enum VideoPosition { + case topLeft + case topRight + case bottomLeft + case bottomRight +} + +class PixelBufferRenderView: UIView { + var uid: UInt = 0 + private var videoWidth: Int32 = 0 + private var videoHeight: Int32 = 0 + + lazy var displayLayer: AVSampleBufferDisplayLayer = { + let layer = AVSampleBufferDisplayLayer() + return layer + }() + + override init(frame: CGRect) { + super.init(frame: frame) + configLayers() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configLayers() { + self.layer.addSublayer(displayLayer) + displayLayer.frame = self.bounds + } + + func createLayer() -> AVSampleBufferDisplayLayer { + let layer = AVSampleBufferDisplayLayer() + return layer + } + + func clean() { + uid = 0 + self.displayLayer.removeFromSuperlayer() + self.displayLayer = createLayer() + self.layer.addSublayer(displayLayer) + } + + func renderFromVideoFrameData(videoData: AgoraOutputVideoFrame, uid: Int) { + let width = videoData.width + let height = videoData.height + let yStride = videoData.yStride + let uStride = videoData.uStride + let vStride = videoData.vStride + + let yBuffer = videoData.yBuffer + let uBuffer = videoData.uBuffer + let vBuffer = videoData.vBuffer + + autoreleasepool { + var pixelBuffer: CVPixelBuffer? + let pixelAttributes: [String: Any] = [kCVPixelBufferIOSurfacePropertiesKey as String: [:]] + + let result = CVPixelBufferCreate(kCFAllocatorDefault, + Int(width), + Int(height), + kCVPixelFormatType_420YpCbCr8Planar, + pixelAttributes as CFDictionary, + &pixelBuffer) + + guard result == kCVReturnSuccess, let pixelBuffer = pixelBuffer else { + print("Unable to create CVPixelBuffer: \(result)") + return + } + + CVPixelBufferLockBaseAddress(pixelBuffer, .init(rawValue: 0)) + let yPlane = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0) + let pixelBufferYBytes = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0) + + for i in 0.. 0, videoHeight > 0, !CGSizeEqualToSize(self.frame.size, CGSize.zero) else { + return + } + + let viewWidth = self.frame.size.width + let viewHeight = self.frame.size.height + + let videoRatio = CGFloat(videoWidth) / CGFloat(videoHeight) + let viewRatio = viewWidth / viewHeight + + var videoSize = CGSize.zero + if videoRatio >= viewRatio { + videoSize.height = viewHeight + videoSize.width = videoSize.height * videoRatio + } else { + videoSize.width = viewWidth + videoSize.height = videoSize.width / videoRatio + } + + let xOffset = max(0, (viewWidth - videoSize.width) / 2) + let yOffset = max(0, (viewHeight - videoSize.height) / 2) + let renderRect = CGRect(x: xOffset, y: yOffset, width: videoSize.width, height: videoSize.height) + + if !renderRect.equalTo(displayLayer.frame) { + displayLayer.frame = renderRect + } + } + +} diff --git a/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/zh-Hans.lproj/PictureInPicture.strings b/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/zh-Hans.lproj/PictureInPicture.strings deleted file mode 100644 index c50d8f1d5..000000000 --- a/iOS/APIExample/APIExample/Examples/Advanced/PictureInPicture/zh-Hans.lproj/PictureInPicture.strings +++ /dev/null @@ -1,12 +0,0 @@ - -/* Class = "UINavigationItem"; title = "Join Channel"; ObjectID = "AmK-zc-ByT"; */ -"AmK-zc-ByT.title" = "加入频道"; - -/* Class = "UITextField"; placeholder = "Enter channel name"; ObjectID = "GWc-L5-fZV"; */ -"GWc-L5-fZV.placeholder" = "输入频道名"; - -/* Class = "UIViewController"; title = "Join Channel Video"; ObjectID = "cAG-6V-STC"; */ -"cAG-6V-STC.title" = "画中画"; - -/* Class = "UIButton"; normalTitle = "Join"; ObjectID = "kbN-ZR-nNn"; */ -"kbN-ZR-nNn.normalTitle" = "加入频道"; diff --git a/iOS/APIExample/Podfile b/iOS/APIExample/Podfile index 1a98a1409..4e2cea8d0 100644 --- a/iOS/APIExample/Podfile +++ b/iOS/APIExample/Podfile @@ -13,6 +13,8 @@ target 'APIExample' do # pod 'MobileVLCKit', '3.5.1' pod 'SwiftLint', '~> 0.53.0' pod 'AgoraRtcEngine_iOS', '4.4.0' + pod 'SnapKit', '~> 5.7.0' + # pod 'sdk', :path => 'sdk.podspec' # pod 'senseLib', :path => 'sense.podspec' # pod 'bytedEffect', :path => 'bytedEffect.podspec'