tencent cloud

云顾问-Tencent RTC 云助手

产品动态
产品简介
产品概述
产品优势
应用场景
购买指南
新手指引
场景化方案
场景化方案概述
社交娱乐
电商直播
音视频通话
远程实时操控
智能客服
AI 面试
模块化方案
模块化方案概述
网络质量监控
移动端应用保活方案
视频画中画方案
直播上下滑
跨房 PK 连麦方案
AI 对话 Chat 信令方案
常见问题
联系我们

视频画中画方案

PDF
聚焦模式
字号
最后更新时间: 2025-11-18 15:15:33
在互动直播等视频场景中,移动端设备观众在长时间观看主播画面时,存在需要临时操作其他 APP 的场景,此时如果能够不中断主播的画面,同时操作其他 APP,会给观众带来更好的用户体验。视频画中画就是针对此场景的解决方案,实现效果如下图。本文将分别对 iOS、Android 和 Flutter 端画中画的实现进行介绍。



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

iOS 端观众画中画实现

开启对应权限

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




调用 SDK 实现

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

RTC Engine 播放

说明:
需要 RTC Engine 的 SDK 在12.1及以上版本。
在观众端调用如下接口开启。
objectivec
swift
NSDictionary *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 即可。

调用系统 API 实现

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




说明:
实现单个主播或多于2个主播画中画依旧可以参考如下方案实现,这里仅对2个主播 PK 的场景进行说明。
1. 定义画中画需要的组件。
因为 iOS 系统要求只能特定的组件才能渲染到画中画中,这里使用 AVSampleBufferDisplayLayer,并且需要该组件直接渲染对应视频,所以定义一个 combinedPixelBuffer 用于合并2个主播的视频数据。
import UIKit
import AVKit
import CoreFoundation
import TXLiteAVSDK_Professional

class PipVC: UIViewController {
let trtcCloud = TRTCCloud()
var pipController: AVPictureInPictureController?
var combinedPixelBuffer: CVPixelBuffer?
let pixelBufferLock = DispatchQueue(label: "com.demo.pip")
var pipDisplayLayer: AVSampleBufferDisplayLayer!
}
2. 进入 RTC Engine 房间。
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:拼接左右2个画面中的方法是相关的,不同的格式需要不同的方法来拼接,该示例仅展示 ._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个画面。
在拼接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()
注意:
这里仅对实现方案进行说明,实际业务中还需要对各种可能的异常情况进行处理。
对于画中画上层控制按钮的处理是 iOS 系统相关能力,不涉及到 SDK,这里不做说明,需要业务根据实际需要进行处理。

Android 端画中画的实现

从 Android 8.0(API 级别26)开始,Android 允许以画中画(PIP)模式启动 activity。画中画是一种特殊类型的多窗口模式,最常用于视频播放。使用该模式,用户可以通过固定到屏幕一角的小窗口观看视频,同时在应用之间进行导航或浏览主屏幕上的内容。RTC Engine SDK没有对 Android 画中画 API 进一步封装,画中画的功能是直接调用 Android API 实现的。如需了解更多信息,请参见 Android 文档 使用画中画(PIP)功能添加视频
Android 端在进入画中画时,会根据 xml 的布局规则,以画中画的窗口大小,重新进行 measure、layout。所以主播端和观众端都可按照此规则实现画中画。

画中画的实现

下面是根据 Android 文档 使用画中画(PIP)功能添加视频 来实现。
1. 在 AndroidManifest.xml 中对<activity>声明画中画属性。
<activity
android:name="com.tencent.trtc.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. 进出画中画的回调。
@Override
public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, configuration);
if (isInPictureInPictureMode) {
//进入画中画后,需要隐藏的view
} else{
//退出画中画后,需要显示的view
}
}

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

想要显示多个视频画面,可以在进入画中画时,对 View A 设置固定的宽高,其他 View 会根据布局规则来显示或者设置百分比布局。
说明:
在画中画中显示多个视频画面方式不是 Android 的规定用法,且当前可在 Android 12上使用,后续可能会随着 Android 系统的更新而变化,需要在发布前测试各版本系统的兼容性。

效果展示




// mTRTCCloud 对应左边的视频View(TXCloudVideoView),设置了TRTC_VIDEO_RENDER_MODE_FIT
TRTCCloudDef.TRTCRenderParams param = new TRTCCloudDef.TRTCRenderParams();
param.fillMode = TRTCCloudDef.TRTC_VIDEO_RENDER_MODE_FIT;
mTRTCCloud.setRemoteRenderParams(remoteUserIdA,TRTCCloudDef.TRTC_VIDEO_STREAM_TYPE_BIG, param);
mTRTCCloud.startRemoteView(remoteUserIdA, TRTCCloudDef.TRTC_VIDEO_STREAM_TYPE_BIG, mTXCloudRemoteView);


// mTRTCCloud 对应右边的视频View(TXCloudVideoView)
mTRTCCloud.startRemoteView(remoteUserIdB, TRTCCloudDef.TRTC_VIDEO_STREAM_TYPE_BIG, mTXCloudRemoteView);
进入画中画后的布局,可以计算并手动设置 TXCloudVideoView 的宽高或者设置填充方式,来保障视频画面的完整显示。调用 mTRTCCloud(TRTCCloud 对象)的 setRemoteRenderParams 方法,来设置视频画面的填充方式。
画中画左边 TXCloudVideoView 是设置了 TRTC_VIDEO_RENDER_MODE_FIT 的效果。
画中画右边 TXCloudVideoView 是设置了 TRTC_VIDEO_RENDER_MODE_FILL 的效果。
此示例中,只有两个视频画面(TXCloudVideoView),对左边的 TXCloudVideoView 设置宽高,右边的 TXCloudVideoView 会根据布局规则来显示。如果您有多个 TXCloudVideoView,可以合理设计布局,达到目标效果。

实现步骤

1. 在 activity_picture_in_picture.xml 中添加两个 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 的宽高。
@Override
public 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 平台进行说明。

发布到 iOS 设备

调用 SDK 实现

Flutter端同样可以通过调用 SDK 提供的 API 方便的开启画中画,和原生 iOS 端一样,SDK 只提供观看单个主播的画中画能力,如果需要画中画中显示多个主播的视频,请参见 调用系统 API 实现
注意:
同样需要在 Flutter 生成的 iOS 工程中开启对应的权限,可参见本文 开启对应权限 部分。
RTC Engine 播放
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 UIKit
import AVKit
import TXLiteAVSDK_Professional

class 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 的视图之上,所以在销毁当前页面时,需要停止画中画,将对应的视图从根视图中移除。
@override
dispose() {
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();
}
}

@Override
public 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,可以通过合理设置布局规则和大小来实现。


帮助和支持

本页内容是否解决了您的问题?

填写满意度调查问卷,共创更好文档体验。

文档反馈