Flutter PIP 插件 ---- iOS Video Call 自定义PIP WINDOW渲染内容

简介

画中画(Picture in Picture, PiP)是一项允许用户在使用其他应用时继续观看视频内容的功能。本文将详细介绍如何在 iOS 应用中实现 PiP 功能,包括自定义内容渲染和控制系统控件的显示。

效果展示

PiP 效果演示

功能特性

已完成功能

  • ✅ 基础 PiP 接口实现(设置、启动、停止、释放等)
  • ✅ 支持自定义内容渲染,将 PiP 窗口渲染内容与插件分离
  • ✅ 支持 PiP 窗口控制样式(显示/隐藏系统控件)
  • ✅ 支持后台自动进入 PiP 模式
  • ✅ 支持调整 PiP 窗口大小和比例
  • ✅ 提供自定义窗口渲染内容 Demo(UIView 循环播放图片)

待实现功能

  • ⏳ 播放事件监听与资源优化
  • ⏳ 根据系统版本和应用类型自动切换实现方式
  • ⏳ 通过 MPNowPlayingSession 更新播放信息
  • ⏳ 细节优化和最佳实践示例

实现原理

Apple 官方文档主要描述了基于 AVPlayer 的 PiP 实现和 VOIP PiP,对于自定义渲染和控制样式等高级功能描述较少。本文结合实践经验,提供完整的实现方案。

核心思路

  1. PiP 窗口显示

    核心是将 UIView(AVSampleBufferDisplayLayer) 插入到指定的 contentSourceView 中,并渲染透明图像。这样既不影响原有内容,又能实现 PiP 功能。

  2. 自定义内容渲染

    通过动态添加自定义 UIView 到 PiP 窗口实现,而不是使用标准的视频帧显示方式。这种方式更灵活,便于封装。

技术要点

关键注意事项

  1. 音频会话设置

    即使视频没有声音,也需要设置 audio session 为 movie playback,否则应用进入后台时 PiP 窗口不会打开。

  2. 控件显示控制

    除了 requiresLinearPlayback 可以控制快进/后退按钮外,其他控件(如播放/暂停按钮、进度条)需要通过 KVO 设置 controlStyle

  3. 视图控制器访问

    无法直接访问 PiP 窗口的 ViewController,目前有两种方案:

    • 获取当前 activate window 添加视图
    • 通过反射获取 Controller 的私有属性 viewController

    注意:使用私有 API 可能有上架风险,建议寻找更稳定的替代方案。

实现步骤

1. 创建 PipView

PipView.h

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@class AVSampleBufferDisplayLayer;

@interface PipView : UIView

@property (nonatomic) AVSampleBufferDisplayLayer *sampleBufferDisplayLayer;

- (void)updateFrameSize:(CGSize)frameSize;

@end

NS_ASSUME_NONNULL_END

PipView.m

#import "PipView.h"
#import <AVFoundation/AVFoundation.h>

@implementation PipView

+ (Class)layerClass {
    return [AVSampleBufferDisplayLayer class];
}

- (AVSampleBufferDisplayLayer *)sampleBufferDisplayLayer {
    return (AVSampleBufferDisplayLayer *)self.layer;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        self.alpha = 0;
    }
    return self;
}

- (void)updateFrameSize:(CGSize)frameSize {
    CMTimebaseRef timebase;
    CMTimebaseCreateWithSourceClock(nil, CMClockGetHostTimeClock(), &timebase);
    CMTimebaseSetTime(timebase, kCMTimeZero);
    CMTimebaseSetRate(timebase, 1);
    self.sampleBufferDisplayLayer.controlTimebase = timebase;
    if (timebase) {
        CFRelease(timebase);
    }

    CMSampleBufferRef sampleBuffer =
        [self makeSampleBufferWithFrameSize:frameSize];
    if (sampleBuffer) {
        [self.sampleBufferDisplayLayer enqueueSampleBuffer:sampleBuffer];
        CFRelease(sampleBuffer);
    }
}

- (CMSampleBufferRef)makeSampleBufferWithFrameSize:(CGSize)frameSize {
    size_t width = (size_t)frameSize.width;
    size_t height = (size_t)frameSize.height;

    const int pixel = 0xFF000000; // {0x00, 0x00, 0x00, 0xFF};//BGRA

    CVPixelBufferRef pixelBuffer = NULL;
    CVPixelBufferCreate(NULL, width, height, kCVPixelFormatType_32BGRA,
                        (__bridge CFDictionaryRef)
                            @{(id)kCVPixelBufferIOSurfacePropertiesKey : @{}},
                        &pixelBuffer);
    CVPixelBufferLockBaseAddress(pixelBuffer, 0);
    int *bytes = CVPixelBufferGetBaseAddress(pixelBuffer);
    for (NSUInteger i = 0, length = height *
                                    CVPixelBufferGetBytesPerRow(pixelBuffer) / 4;
         i < length; ++i) {
        bytes[i] = pixel;
    }
    CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
    CMSampleBufferRef sampleBuffer =
        [self makeSampleBufferWithPixelBuffer:pixelBuffer];
    CVPixelBufferRelease(pixelBuffer);
    return sampleBuffer;
}

- (CMSampleBufferRef)makeSampleBufferWithPixelBuffer:
    (CVPixelBufferRef)pixelBuffer {
    CMSampleBufferRef sampleBuffer = NULL;
    OSStatus err = noErr;
    CMVideoFormatDescriptionRef formatDesc = NULL;
    err = CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault,
                                                       pixelBuffer, &formatDesc);

    if (err != noErr) {
        return nil;
    }

    CMSampleTimingInfo sampleTimingInfo = {
        .duration = CMTimeMakeWithSeconds(1, 600),
        .presentationTimeStamp =
            CMTimebaseGetTime(self.sampleBufferDisplayLayer.timebase),
        .decodeTimeStamp = kCMTimeInvalid};

    err = CMSampleBufferCreateReadyWithImageBuffer(
        kCFAllocatorDefault, pixelBuffer, formatDesc, &sampleTimingInfo,
        &sampleBuffer);

    if (err != noErr) {
        return nil;
    }

    CFRelease(formatDesc);

    return sampleBuffer;
}

@end

2. 配置 PiP 控制器

// 创建 PipView
PipView *pipView = [[PipView alloc] init];
pipView.translatesAutoresizingMaskIntoConstraints = NO;

// 添加到源视图
[currentVideoSourceView insertSubview:pipView atIndex:0];
[pipView updateFrameSize:CGSizeMake(100, 100)];

// 创建内容源
AVPictureInPictureControllerContentSource *contentSource =
    [[AVPictureInPictureControllerContentSource alloc]
        initWithSampleBufferDisplayLayer:pipView.sampleBufferDisplayLayer
                        playbackDelegate:self];

// 创建 PiP 控制器
AVPictureInPictureController *pipController =
    [[AVPictureInPictureController alloc] initWithContentSource:contentSource];
pipController.delegate = self;
pipController.canStartPictureInPictureAutomaticallyFromInline = YES;

3. 设置控制样式

// 控制快进/后退按钮
pipController.requiresLinearPlayback = YES;

// 控制其他控件
[pipController setValue:@(1) forKey:@"controlsStyle"]; // 隐藏前进/后退、播放/暂停按钮和进度条
// [pipController setValue:@(2) forKey:@"controlsStyle"]; // 隐藏所有系统控件

4. 处理播放代理

- (CMTimeRange)pictureInPictureControllerTimeRangeForPlayback:
    (AVPictureInPictureController *)pictureInPictureController {
    return CMTimeRangeMake(kCMTimeZero, kCMTimePositiveInfinity);
}

5. 管理自定义视图

// 添加自定义视图
- (void)pictureInPictureControllerDidStartPictureInPicture:
    (AVPictureInPictureController *)pictureInPictureController {
    [pipViewController.view insertSubview:contentView atIndex:0];
    [pipViewController.view bringSubviewToFront:contentView];
    
    // 设置约束
    contentView.translatesAutoresizingMaskIntoConstraints = NO;
    [pipViewController.view addConstraints:@[
        [contentView.leadingAnchor constraintEqualToAnchor:pipViewController.view.leadingAnchor],
        [contentView.trailingAnchor constraintEqualToAnchor:pipViewController.view.trailingAnchor],
        [contentView.topAnchor constraintEqualToAnchor:pipViewController.view.topAnchor],
        [contentView.bottomAnchor constraintEqualToAnchor:pipViewController.view.bottomAnchor],
    ]];
}

// 移除自定义视图
- (void)pictureInPictureControllerDidStopPictureInPicture:
    (AVPictureInPictureController *)pictureInPictureController {
    [contentView removeFromSuperview];
}

参考资源

项目地址

欢迎 Star 支持!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值