TRTC 云助手 视频画中画方案

在互动直播等视频场景中,移动端设备观众在长时间观看主播画面时,存在需要临时操作其他 App 的场景,此时如果能够不中断主播的画面,同时操作其他 App,会给观众带来更好的用户体验。视频画中画就是针对此场景的解决方案,实现效果如下图。这篇将分别对 iOS、Android 和 Flutter 端画中画的实现进行介绍。

相关产品
TRTC 云助手 产品页,含版本规格、价格与购买入口。

画中画是依赖 iOS 和 Android 提供的系统能力实现的,可分为主播端(需要采集摄像头和上行数据)和观众端(仅需下行数据) 。由于 iOS 系统对权限管控比较严格,因此 iOS 端只提供观众端画中画实现,Android 端提供主播和观众端的画中画实现。对于视频播放,一般有使用 TRTC 播放和直播播放两种方案,画中画方案中也分别对这两种情况进行说明。

一、开启对应权限

需要在 iOS 工程的 Signing & Capabilities 处开启以下权限:

二、调用 SDK 实现

iOS 端 SDK 提供了接口来实现画中画的功能,通过调用 API 可以方便的开启画中画(相关 API 见下面的示例代码),但 SDK 只提供观看单个主播的画中画能力,如果需要支持观看多个主播 PK 画中画的能力,需要调用系统的 API 来实现,详情请参见 调用系统 API 实现。

三、TRTC 播放

注意:说明:

注意:需要 TRTC 的 SDK 在12.1及以上版本。

在观众端调用如下接口开启。

objectivecswiftNSDictionary *param = @{ @"api" : @"enablePictureInPictureFloatingWindow", @"params" : @{ @"enable" : @(true) }};NSError *err = nil;NSData *jsonData = [NSJSONSerialization dataWithJSONObject:param options:0 error:&err];if (err) { NSLog(@"error: %@", err);}NSString *paramJsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];[self.trtcCloud callExperimentalAPI:paramJsonString];let param: [String : Any] = ["api": "enablePictureInPictureFloatingWindow", "params": ["enable":true]]if let jsonData = try? JSONSerialization.data(withJSONObject: param, options: .fragmentsAllowed) { let paramJsonString = String.init(data: jsonData, encoding: .utf8) ?? "" trtcCloud.callExperimentalAPI(paramJsonString)}

如果需要关闭,在对应参数位置传入 false 即可。

四、直播播放

在观众端调用如下接口开启。

objectivecswift[self.livePlayer enablePictureInPicture:true];player.enablePicture(inPicture: true);

如果需要关闭,在对应参数位置传入 false 即可。

五、调用系统 API 实现

画中画是 iOS 系统提供的能力,通过调用系统 API 能实现复杂场景的画中画能力。iOS 虽然支持画中画能力,但对该特性有较多的限制,无法直接使用渲染视频的 UIView 来实现画中画,需要使用自定义渲染,将需要展示在画中画的视频渲染到符合要求的组件上。下面以2个主播 PK 画中画的场景为例,介绍调用系统 API 对画中画的实现。

注意:说明:

注意:实现单个主播或多于2个主播画中画依旧可以参考如下方案实现,这里仅对2个主播 PK 的场景进行说明。

1. 定义画中画需要的组件。

因为 iOS 系统要求只能特定的组件才能渲染到画中画中,这里使用 AVSampleBufferDisplayLayer,并且需要该组件直接渲染对应视频,所以定义一个 combinedPixelBuffer 用于合并2个主播的视频数据。

import UIKitimport AVKitimport CoreFoundationimport TXLiteAVSDK_Professionalclass PipVC: UIViewController { let trtcCloud = TRTCCloud() var pipController: AVPictureInPictureController? var combinedPixelBuffer: CVPixelBuffer? let pixelBufferLock = DispatchQueue(label: "com.demo.pip") var pipDisplayLayer: AVSampleBufferDisplayLayer!}

2. 进入 TRTC 房间。

func enterTrtcRoom() { let params = TRTCParams() params.sdkAppId = UInt32(SDKAppID) params.roomId = UInt32(roomId) params.userId = userId params.role = .audience params.userSig = GenerateTestUserSig.genTestUserSig(identifier: userId) as String trtcCloud.addDelegate(self) trtcCloud.enterRoom(params, appScene: .LIVE) }

3. 设置音频并开启后台解码。

func setupAudioSession() { do { try AVAudioSession.sharedInstance().setCategory(.playback) } catch let error { print("+> error: \\(error)") return } do { try AVAudioSession.sharedInstance().setActive(true) } catch let error { print("+> error: \\(error)") return }}func enableBGDecode() { let param: [String : Any] = ["api": "enableBackgroundDecoding", "params": ["enable":true]] if let jsonData = try? JSONSerialization.data(withJSONObject: param, options: .fragmentsAllowed) { let paramJsonString = String.init(data: jsonData, encoding: .utf8) ?? "" trtcCloud.callExperimentalAPI(paramJsonString) }}

4. 初始化画中画组件。

func setupPipController() { let screenWidth = UIScreen.main.bounds.width let videoHeight = screenWidth / 2 / 9 * 16 pipDisplayLayer = AVSampleBufferDisplayLayer() pipDisplayLayer.frame = CGRect(x: 0, y: 0, width: screenWidth, height: videoHeight) // Adjust size as needed pipDisplayLayer.videoGravity = .resizeAspect pipDisplayLayer.isOpaque = true pipDisplayLayer.backgroundColor = CGColor(red: 0, green: 0, blue: 0, alpha: 1) view.layer.addSublayer(pipDisplayLayer) if AVPictureInPictureController.isPictureInPictureSupported() { let contentSource = AVPictureInPictureController.ContentSource( sampleBufferDisplayLayer: pipDisplayLayer, playbackDelegate: self ) pipController = AVPictureInPictureController(contentSource: contentSource) pipController?.delegate = self pipController?.canStartPictureInPictureAutomaticallyFromInline = true } else { print("+> error") }}

5. 开启自定义渲染。

注意:开启自定义渲染时指定的格式 ._NV12和步骤6:拼接左右两个画面中的方法是相关的,不同的格式需要不同的方法来拼接,该示例仅展示 ._NV12格式的左右拼接。

extension PipVC: TRTCCloudDelegate { func onUserVideoAvailable(_ userId: String, available: Bool) { if available { trtcCloud.startRemoteView(userId, streamType: .big, view: nil) trtcCloud.setRemoteVideoRenderDelegate(userId, delegate: self, pixelFormat: ._NV12, bufferType: .pixelBuffer); }else{ trtcCloud.stopRemoteView(userId, streamType: .big) } }}

6. 拼接左右2个画面。

在拼接两个主播的视频数据时,因为 SDK 回调出视频数据的时间不同步,所以需要在每次收到单个主播的视频数据时去更新对应的数据,并加锁,如果需要实现多个主播的情况,也需要按照类似的方法操作。以下代码是按照左右2个主播各占一半的方法布局,如果需要其他布局,需要业务上根据需要进行实现,这里不涉及 SDK。

func createCombinedPixelBuffer(from sourceBuffer: CVPixelBuffer) { let width = CVPixelBufferGetWidth(sourceBuffer) * 2 let height = CVPixelBufferGetHeight(sourceBuffer) let pixelFormat = CVPixelBufferGetPixelFormatType(sourceBuffer) let attributes: [CFString: Any] = [ kCVPixelBufferWidthKey: width, kCVPixelBufferHeightKey: height, kCVPixelBufferPixelFormatTypeKey: pixelFormat, kCVPixelBufferIOSurfacePropertiesKey: [:] ] CVPixelBufferCreate(kCFAllocatorDefault, width, height, pixelFormat, attributes as CFDictionary, &combinedPixelBuffer)}func updateCombinedPixelBuffer(with sourceBuffer: CVPixelBuffer, forLeft: Bool) { guard let combinedBuffer = combinedPixelBuffer else { print("+> error"); return} CVPixelBufferLockBaseAddress(combinedBuffer, []) CVPixelBufferLockBaseAddress(sourceBuffer, []) // Plane 0: Y/luma plane let combinedLumaBaseAddress = CVPixelBufferGetBaseAddressOfPlane(combinedBuffer, 0)! let sourceLumaBaseAddress = CVPixelBufferGetBaseAddressOfPlane(sourceBuffer, 0)! let combinedLumaBytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(combinedBuffer, 0) let sourceLumaBytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(sourceBuffer, 0) let widthLuma = CVPixelBufferGetWidthOfPlane(sourceBuffer, 0) let heightLuma = CVPixelBufferGetHeightOfPlane(sourceBuffer, 0) // Plane 1: UV/chroma plane let combinedChromaBaseAddress = CVPixelBufferGetBaseAddressOfPlane(combinedBuffer, 1)! let sourceChromaBaseAddress = CVPixelBufferGetBaseAddressOfPlane(sourceBuffer, 1)! let combinedChromaBytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(combinedBuffer, 1) let sourceChromaBytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(sourceBuffer, 1) let widthChroma = CVPixelBufferGetWidthOfPlane(sourceBuffer, 1) let heightChroma = CVPixelBufferGetHeightOfPlane(sourceBuffer, 1) for row in 0..<heightLuma { let combinedRow = combinedLumaBaseAddress.advanced(by: row * combinedLumaBytesPerRow + (forLeft ? 0 : widthLuma)) let sourceRow = sourceLumaBaseAddress.advanced(by: row * sourceLumaBytesPerRow) memcpy(combinedRow, sourceRow, widthLuma) } // ._nv12 the chroma plane is subsampled 2:1 horizontally and vertically for row in 0..<heightChroma { let combinedRow = combinedChromaBaseAddress.advanced(by: row * combinedChromaBytesPerRow + (forLeft ? 0 : 2 * widthChroma)) let sourceRow = sourceChromaBaseAddress.advanced(by: row * sourceChromaBytesPerRow) memcpy(combinedRow, sourceRow, 2 * widthChroma) } CVPixelBufferUnlockBaseAddress(sourceBuffer, []) CVPixelBufferUnlockBaseAddress(combinedBuffer, [])}

7. 将合并后的画面渲染到对应的组件上。

func displayPixelBuffer(_ pixelBuffer: CVPixelBuffer, in layer: AVSampleBufferDisplayLayer) { var timing = CMSampleTimingInfo.init(duration: .invalid, presentationTimeStamp: .invalid, decodeTimeStamp: .invalid) var videoInfo: CMVideoFormatDescription? = nil var result = CMVideoFormatDescriptionCreateForImageBuffer(allocator: nil, imageBuffer: pixelBuffer, formatDescriptionOut: &videoInfo) if result != 0 { return } guard let videoInfo = videoInfo else { return } var sampleBuffer: CMSampleBuffer? = nil result = CMSampleBufferCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, dataReady: true, makeDataReadyCallback: nil, refcon: nil, formatDescription: videoInfo, sampleTiming: &timing, sampleBufferOut: &sampleBuffer) if result != 0 { return } guard let sampleBuffer = sampleBuffer else { return } guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: true) else { return } CFDictionarySetValue( unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self), Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(), Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()) layer.enqueue(sampleBuffer) if layer.status == .failed { if let error = layer.error as? NSError { if error.code == -11847 { print("+> error") } } } }

8. 拿到远端用户的视频数据,拼接后渲染到指定的组件。

示例中使用 left 标识显示在左边的主播 ID,实际业务中需要根据业务需要进行修改。

extension PipVC: TRTCVideoRenderDelegate { func onRenderVideoFrame(_ frame: TRTCVideoFrame, userId: String?, streamType: TRTCVideoStreamType) { guard let newPixelBuffer = frame.pixelBuffer else { print("+> error"); return} pixelBufferLock.sync { if combinedPixelBuffer == nil { createCombinedPixelBuffer(from: newPixelBuffer) } if userId == "left" { updateCombinedPixelBuffer(with: newPixelBuffer, forLeft: true) } else { updateCombinedPixelBuffer(with: newPixelBuffer, forLeft: false) } } if let combinedBuffer = combinedPixelBuffer { DispatchQueue.main.async { self.displayPixelBuffer(combinedBuffer, in: self.pipDisplayLayer) } } }}

9. 实现相关协议。

extension PipVC: AVPictureInPictureControllerDelegate { func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { } func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { } func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { } func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { completionHandler(true) } func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: any Error) { } }extension PipVC: AVPictureInPictureSampleBufferPlaybackDelegate { func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange { return CMTimeRange.init(start: .zero, duration: .positiveInfinity) } func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool { return false } func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) { } func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) { } func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime) async { }}

10. 开启/关闭画中画。

// 关闭画中画pipController?.stopPictureInPicture()// 开启画中画pipController?.startPictureInPicture()

六、Android 端画中画的实现

从 Android 8.0(API 级别26)开始,Android 允许以画中画(PIP)模式启动 activity。画中画是一种特殊类型的多窗口模式,最常用于视频播放。使用该模式,用户可以通过固定到屏幕一角的小窗口观看视频,同时在应用之间进行导航或浏览主屏幕上的内容。TRTC SDK、直播 SDK 没有对 Android 画中画 API 进一步封装,TRTC Demo、云直播 Demo 画中画的功能是直接调用 Android API 实现的。如需了解更多信息,请参见 Android 文档 使用画中画(PIP)功能添加视频。

Android 端在进入画中画时,会根据 XML 的布局规则,以画中画的窗口大小,重新进行 measure、layout。所以主播端和观众端都可按照此规则实现画中画。

七、画中画的实现

以下结合 MLVB-API-Example 工程,介绍画中画功能的实现,你也可以根据 Android 文档 使用画中画(PIP)功能添加视频 来实现。

下载 MLVB-API-Example 工程后, 画中画的功能代码会在 MLVB-API-Example/Advanced/PictureInPicture 路径下。

1. 在 AndroidManifest.xml 中对<activity>声明画中画属性。

<activity android:name="com.tencent.mlvb.pictureinpicture.PictureInPictureActivity" android:theme="@style/Theme.AppCompat.Light.NoActionBar" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" android:supportsPictureInPicture="true"

·android:supportsPictureInPicture="true" 表示声明支持画中画。

·在画中画模式转换期间出现布局更改,如果不希望 activity 重启动,需要配置 android:configChanges 属性的对应值。

2. 进入画中画。

private void startPictureInPicture() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { PictureInPictureParams.Builder pictureInPictureBuilder = new PictureInPictureParams.Builder(); Rational aspectRatio = new Rational(mVideoView.getWidth(), mVideoView.getHeight()); pictureInPictureBuilder.setAspectRatio(aspectRatio); //进入画中画 enterPictureInPictureMode(pictureInPictureBuilder.build()); } else { Toast.makeText(this, R.string.picture_in_picture_not_supported, Toast.LENGTH_SHORT).show(); }}

·pictureInPictureBuilder.setAspectRatio(aspectRatio); 设置画中画的宽高比,此处设置为播放视频 View 的宽高比。

·enterPictureInPictureMode(pictureInPictureBuilder.build()); 进入画中画。

3. 进出画中画的回调。

@Overridepublic void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, configuration); if (isInPictureInPictureMode) { //进入画中画后,需要隐藏的view } else{ //退出画中画后,需要显示的view }}

八、在画中画中显示多个视频画面

想要显示多个视频画面,可以在进入画中画时,对 View A 设置固定的宽高,其他 View 会根据布局规则来显示或者设置百分比布局。

注意:说明:

注意:在画中画显示多个视频画面方式不是 Android 的规定用法,且当前可在 Android 12上使用,后续可能会随着 Android 系统的更新而变化,需要在发布前测试各版本系统的兼容性。

九、效果展示

// mLivePlayer 对应左边的视频View(TXCloudVideoView),设置了V2TXLiveFillModeFitmLivePlayer.startLivePlay(PLAY_URL);mLivePlayer.setRenderFillMode(V2TXLiveDef.V2TXLiveFillMode.V2TXLiveFillModeFit);// mLivePlayer2 对应右边的视频View(TXCloudVideoView)mLivePlayer2.startLivePlay(PLAY_URL);

进入画中画后的布局,可以计算并手动设置 TXCloudVideoView 的宽高,或者设置填充方式,来保障视频画面的完整显示。调用 mLivePlayer(V2TXLivePlayer 对象)的 setRenderFillMode 方法,来设置视频画面的填充方式。

·画中画左边 TXCloudVideoView 是设置了 V2TXLiveFillModeFit 的效果。

·画中画右边 TXCloudVideoView 是设置了 V2TXLiveFillModeFill 的效果。

此示例中,只有两个视频画面(TXCloudVideoView),对左边的 TXCloudVideoView 设置宽高,右边的 TXCloudVideoView 会根据布局规则来显示。如果你有多个 TXCloudVideoView,可以合理设计布局,达到目标效果。

十、实现步骤

1. 在 activity_picture_in_picture.xml 中添加一个 TXCloudVideoView,两个 TXCloudVideoView 并排显示。

<com.tencent.rtmp.ui.TXCloudVideoView android:id="@+id/video_view" android:layout_width="192dp" android:layout_height="108dp" android:layout_alignParentStart="true" android:background="#00BCD4"/><com.tencent.rtmp.ui.TXCloudVideoView android:id="@+id/video_view2" android:layout_width="192dp" android:layout_height="108dp" android:layout_alignTop="@+id/video_view" android:layout_toEndOf="@+id/video_view" android:background="#3F51B5"/>

2. 在进入和退出画中画的时候,设置 video_view 的宽高。

@Overridepublic void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, configuration); if (isInPictureInPictureMode) { // 设置mVideoView 的宽为 100dp RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) mVideoView.getLayoutParams(); layoutParams.width = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 100, getResources().getDisplayMetrics()); } else { // 退出画中画,还原video_view的宽度 RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) mVideoView.getLayoutParams(); layoutParams.width = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 192, getResources().getDisplayMetrics()); }}

十一、Flutter 端观众画中画实现

Flutter 端开启画中画在不同平台有不同的实现,下面分别对 iOS 和 Android 平台进行说明。

十二、调用 SDK 实现

Flutter端同样可以通过调用 SDK 提供的 API 方便的开启画中画,和原生 iOS 端一样,SDK 只提供观看单个主播的画中画能力,如果需要画中画中显示多个主播的视频,请参见 调用系统 API 实现。

注意:同样需要在 Flutter 生成的 iOS 工程中开启对应的权限,可参见这篇 开启对应权限 部分。

TRTC 播放

Flutter SDK 需要2.9.1版本及以后,在观众端调用如下接口实现。

trtcCloud.callExperimentalAPI(jsonEncode({ "api": "enablePictureInPictureFloatingWindow", "params": {"enable": true}}));

如果需要关闭,在对应参数位置传入 false 即可。

直播播放

在观众端调用如下接口开启。

var pipCode = await _livePlayer!.enablePictureInPicture(true);if (pipCode != V2TXLIVE_OK) { print("error: $pipCode");}

如果需要关闭,在对应参数位置传入 false 即可。

十三、调用系统 API 实现

如果需要实现复杂的画中画能力,例如在画中画中显示多个主播的视频,需要调用 iOS 系统提供的 API 来实现,请参见 iOS 端观众画中画实现-调用系统 API 实现 部分。下面对 Flutter 端调用 iOS 系统 API 部分进行说明。

1. Flutter 端使用 methodChannel 发消息到 iOS 原生端。

final channel = MethodChannel('flutter_ios_pip_demo');await channel.invokeMethod('enablePip', { 'marginTop': appBarHeight + topSafeAreaHeight, 'pkLeft': pkLeftUserId, 'pkRight': pkRightUserId, });

2. 在 Flutter 打包出的 iOS 工程中,接收对应的消息,并做相应处理。

Flutter 调用系统 API 实现多主播 PK 画中画时,实际上是调用 iOS 系统 API,使用自定义采集重新绘制2个主播 PK 的画面,并显示在 Flutter 层之上,所以需要在调用 iOS 系统 API 绘制的窗口大小和位置和 Flutter 端一致,可以在 methodChannel 中传递相应的布局参数和主播 ID。

var channel: FlutterMethodChannel?let pipListener = PipRender()guard let controller = window?.rootViewController as? FlutterViewController else { fatalError("Invalid root view controller")}channel = FlutterMethodChannel(name: "flutter_ios_pip_demo", binaryMessenger: controller.binaryMessenger)channel?.setMethodCallHandler({ [weak self] call, result in guard let self = self else { return } switch (call.method) { case "enablePip": if let arg = call.arguments as? [String: Any] { let marginTop = arg["marginTop"] as? CGFloat ?? 0 let pkLeft = arg["pkLeft"] as? String ?? "" let pkRight = arg["pkRight"] as? String ?? "" pipListener.enablePip(mainView: vc.view, mt: mt, pkLeft: pkLeft, pkRight: pkRight) } result(nil) break case "disablePip": pipListener.disablePip() result(nil) break default: break }})

3. 定义处理画中画的类。

在开启画中画时,将对应主播的流改为自定义渲染,并将渲染后的画面插入到根视图中,用于显示。

import UIKitimport AVKitimport TXLiteAVSDK_Professionalclass PipRender: NSObject { // 其余变量参考iOS原生端调用系统API实现的部分 var mainView: UIView? var mt: CGFloat? // 因为trtcCloud是单实例的,可以在代码中这样获取 let trtcCloud = TRTCCloud.sharedInstance() func disablePip() { pipDisplayLayer?.removeFromSuperlayer() pipController?.stopPictureInPicture() } func enablePip(mainView: UIView, mt: CGFloat, pkLeft: String, pkRight: String) { self.mainView = mainView self.mt = mt trtcCloud.addDelegate(self) enableBGDecode() setupAudioSession() setupPipController() pipController?.startPictureInPicture() if pkLeft.count > 0 { trtcCloud.startRemoteView(pkLeft, streamType: .big, view: nil) trtcCloud.setRemoteVideoRenderDelegate(pkLeft, delegate: self, pixelFormat: ._NV12, bufferType: .pixelBuffer); } if pkRight.count > 0 { trtcCloud.startRemoteView(pkRight, streamType: .big, view: nil) trtcCloud.setRemoteVideoRenderDelegate(pkRight, delegate: self, pixelFormat: ._NV12, bufferType: .pixelBuffer); } } // 该方法需要根据业务需要,调整画中画显示的位置,保证和Flutter端显示的位置一致 func setupPipController() { let screenWidth = UIScreen.main.bounds.width let videoHeight = screenWidth / 2 / 9 * 16 pipDisplayLayer = AVSampleBufferDisplayLayer() // 这里根据实际需要,调整画中画显示的位置 let tsa = self.mainView?.safeAreaInsets.top ?? let vmt = tsa + (self.mt ?? 0) pipDisplayLayer.frame = CGRect(x: 0, y: vmt, width: screenWidth, height: videoHeight) // Adjust size as needed pipDisplayLayer.videoGravity = .resizeAspect pipDisplayLayer.isOpaque = true pipDisplayLayer.backgroundColor = CGColor(red: 0, green: 0, blue: 0, alpha: 1) // 这里使用enablePip传递进来的mainView添加画中画的画面 mainView?.layer.addSublayer(pipDisplayLayer) if AVPictureInPictureController.isPictureInPictureSupported() { let contentSource = AVPictureInPictureController.ContentSource( sampleBufferDisplayLayer: pipDisplayLayer, playbackDelegate: self ) pipController = AVPictureInPictureController(contentSource: contentSource) pipController?.delegate = self pipController?.canStartPictureInPictureAutomaticallyFromInline = true } else { print("+> PiP not supported") } } // 其余的方法,和iOS原生端调用系统API实现的部分一致}

4. Flutter 端在停止画中画时需要重新拉对应的主播的流,恢复 Flutter 端的渲染。

// 业务上触发停止画中画时trtcCloud.startRemoteView(pkLeftUserId, TRTCCloudDef.TRTC_VIDEO_STREAM_TYPE_BIG, pkLeftId);trtcCloud.startRemoteView(pkRightUserId, TRTCCloudDef.TRTC_VIDEO_STREAM_TYPE_BIG, pkRightId);await channel.invokeMethod('disablePip');

5. Flutter 端在销毁当前页面时需要停止画中画。

因为开始画中画后,实际是调用 iOS 系统 API 重新绘制一个视图,覆盖在 Flutter 的视图之上,所以在销毁当前页面时,需要停止画中画,将对应的视图从根视图中移除。

@overridedispose() { channel.invokeMethod('disablePip'); super.dispose();}

十四、在 Android 上通过 Flutter 实现画中画

在 Flutter 中实现画中画,也是需要调用 Android 画中画的 API。进入画中画后,Flutter UI 会按照已有的 Widget 布局规则进行显示,可以根据自己的业务规则,在进入画中画后,隐藏部分 Widget,合理设置视频 Widget 的宽高。

使用 平台通道调用 Android 的代码,通道分为客户端(Flutter)和宿主端(Android), 下面来看画中画的具体实现:

1. Flutter 客户端的代码:使用通道名"samples.flutter.dev" 调用通道方法"pictureInPicture",这个方法的具体实现在 Android 宿主端。

MethodChannel _channel = MethodChannel('samples.flutter.dev');final int? result = await _channel.invokeMethod('pictureInPicture');

2. Android 宿主端的代码。

2.1 在继承了 FlutterActivity 的 Activity 中实现:

private void startPictureInPicture() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { PictureInPictureParams.Builder pictureInPictureBuilder = new PictureInPictureParams.Builder(); //根据具体的业务需求,设置指定的画中画大小 Rational aspectRatio = new Rational(100, 100); pictureInPictureBuilder.setAspectRatio(aspectRatio); //进入画中画 enterPictureInPictureMode(pictureInPictureBuilder.build()); } else { Toast.makeText(this, R.string.picture_in_picture_not_supported, Toast.LENGTH_SHORT).show(); }}@Overridepublic void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { super.configureFlutterEngine(flutterEngine); MethodChannel channel = new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), "samples.flutter.dev"); channel.setMethodCallHandler( (call, result) -> { if (call.method.equals("pictureInPicture")) { startPictureInPicture(); } else { result.notImplemented(); } } );}

2.2 在 AndroidManifest.xml 中对 activity 配置画中画参数 android:supportsPictureInPicture="true",如下:

<activity android:name="example.android.app.src.main.java.com.tencent.live.example.MainActivity" android:supportsPictureInPicture="true" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" > …</activity>

想要在画中画中显示两个视频画面,例如主播 PK,可以通过合理设置布局规则和大小来实现。

赞 (0)
上一篇 4天前
下一篇 4天前