视频画中画方案

最近更新时间:2025-01-25 18:00:43

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



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

iOS 端观众画中画实现

开启对应权限

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




调用 SDK 实现

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

TRTC 播放

说明:
需要 TRTC 的 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 即可。

直播播放

在观众端调用如下接口开启。
objectivec
swift
[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 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. 进入 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:拼接左右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。画中画是一种特殊类型的多窗口模式,最常用于视频播放。使用该模式,用户可以通过固定到屏幕一角的小窗口观看视频,同时在应用之间进行导航或浏览主屏幕上的内容。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. 进出画中画的回调。
@Override
public 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),设置了V2TXLiveFillModeFit
mLivePlayer.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 的宽高。
@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 工程中开启对应的权限,可参见本文 开启对应权限 部分。
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 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,可以通过合理设置布局规则和大小来实现。