屏幕共享

功能描述

在视频通话或互动直播过程中进行屏幕共享,发言者或主播可以将自己的屏幕内容,以视频的方式分享给远端参会者或观众观看,从而提升沟通效率。

屏幕共享在如下场景中进行广泛应用:

Android

实现方法

示例代码

    //先请求屏幕共享权限
    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private void startScreenCapture() { 
        MediaProjectionManager mediaProjectionManager =
                (MediaProjectionManager) getApplication().getSystemService(
                        Context.MEDIA_PROJECTION_SERVICE);
        startActivityForResult(
                mediaProjectionManager.createScreenCaptureIntent(), CAPTURE_PERMISSION_REQUEST_CODE);
    }

    //在权限请求返回中打开屏幕共享接口
    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        if (requestCode != CAPTURE_PERMISSION_REQUEST_CODE)
            return;
        if(resultCode != Activity.RESULT_OK) {
            showToast("你拒绝了录屏请求!");
            getUiKitButtons().find("screen_cast", Boolean.class).setState(false);
            return;
        }
        NERtcEx.getInstance().enableLocalVideo(false); //先停止视频
        NERtcEx.getInstance().startScreenCapture(mScreenProfile,data, new MediaProjection.Callback() { //2、再创建录屏capturer
            @Override
            public void onStop() {
                super.onStop();
                showToast("录屏已停止");
            }
        });
        getUiKitButtons().find("screen_cast", Boolean.class).setState(true);
    }

    // 停止桌面共享
    NERtcEx.getInstance().stopScreenCapture();

API参考

方法 功能描述
startScreenCapture 开启屏幕共享
stopScreenCapture 停止屏幕共享

开发注意事项

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /><!-- 添加前台服务权限-->

<application>
    <service
        android:name="com.netease.lava.video.device.screencapture.ScreenShareService"
        android:foregroundServiceType="mediaProjection">
        <intent-filter>
            <action android:name="com.netease.Yunxin.ScreenShare" />
        </intent-filter>
    </service>
</application>

iOS

实现方法

iOS实现屏幕分享的基本原理是利用iOS的 ReplayKit 特性。ReplayKit 仅支持iOS 11.0 以上共享系统屏幕。基本流程是添加ReplayKit扩展、云信SDK创建加入房间并使用自定义采集、使用 AppGroup 在宿主App(主工程,这里是屏幕共享Sample)和扩展ReplayKit程序之间进行视频数据(音频使用宿主APP中云信SDK采集)和控制指令传输;

具体实现分为两部分(扩展 ReplayKit 程序, 主工程)。

添加ReplayKit

  1. Broadcast Upload Extension

选择Target,添加 Extension

image

image

  1. 建立同名 AppGroup 数据池,用于扩展 ReplayKit 程序和主工程之间通信
  2. ReplayKit 采集到的屏幕视频数据通过 processSampleBuffer:withType:给用户,忽略音频数据回调(我们使用云信SDK音频采集),将视频数据压缩后存入共用资料夹,主程序监测到视频数据变更后,通过SDK自定义视频数据进行发送。

屏幕分享主程序

  1. 初始化SDK,配置允许使用外部视频源,确保视频通话功能正常;
  2. 在 RPSystemBroadcastPickerView 中添加扩展程序;
  3. 初始化同AppGroup名资料夹, 并添加监听事件;
  4. 监听到数据帧变化, 校验后推送外部视频帧到SDK;

具体代码及流程请参照示例代码和Sample工程

示例代码

ReplayKit部分

  1. 建立同名 AppGroup 数据池,用于扩展 ReplayKit 程序和主工程之间通信
- (void)broadcastStartedWithSetupInfo:(NSDictionary<NSString *,NSObject *> *)setupInfo {
    self.userDefautls = [[NSUserDefaults alloc] initWithSuiteName:<#kAppGroupName#>];
}
  1. 压缩裁剪采集图片,发送到宿主App

ReplayKit 采集到的屏幕视频数据通过 processSampleBuffer:withType:给用户,忽略音频数据回调(我们使用云信SDK音频采集),将视频数据存入共用资料夹,主程序监测倒数据变更后,再通过SDK自定义视频数据进行发送。

- (void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType {

    switch (sampleBufferType) {
        case RPSampleBufferTypeVideo: {
            @autoreleasepool {
                CVImageBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
                NSDictionary *frame = [self createI420VideoFrameFromPixelBuffer:pixelBuffer];
                [self.userDefautls setObject:frame forKey:<#KeyPath#>];
                [self.userDefautls synchronize];
            }
            break;
        }
        case RPSampleBufferTypeAudioApp:
            // Handle audio sample buffer for app audio
            break;
        case RPSampleBufferTypeAudioMic:
            // Handle audio sample buffer for mic audio
            break;

        default:
            break;
    }
}

数据压缩采用的是 libyuv 第三方工具。

- (NSDictionary *)createI420VideoFrameFromPixelBuffer:(CVPixelBufferRef)pixelBuffer
{
    CVPixelBufferLockBaseAddress(pixelBuffer, 0);

    // 转I420
    int psrc_w = (int)CVPixelBufferGetWidth(pixelBuffer);
    int psrc_h = (int)CVPixelBufferGetHeight(pixelBuffer);
    uint8 *src_y = (uint8 *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0);
    uint8 *src_uv = (uint8 *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1);
    int y_stride = (int)CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0);
    int uv_stride = (int)CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1);
    uint8 *i420_buf = (uint8 *)malloc((psrc_w * psrc_h * 3) >> 1);

    libyuv::NV12ToI420(&src_y[0],                              y_stride,
                       &src_uv[0],                             uv_stride,
                       &i420_buf[0],                           psrc_w,
                       &i420_buf[psrc_w * psrc_h],             psrc_w >> 1,
                       &i420_buf[(psrc_w * psrc_h * 5) >> 2],  psrc_w >> 1,
                       psrc_w, psrc_h);

    // 缩放至720
    int pdst_w = 720;
    int pdst_h = psrc_h * (pdst_w/(double)psrc_w);
    libyuv::FilterMode filter = libyuv::kFilterNone;
    uint8 *pdst_buf = (uint8 *)malloc((pdst_w * pdst_h * 3) >> 1);
    libyuv::I420Scale(&i420_buf[0],                          psrc_w,
                      &i420_buf[psrc_w * psrc_h],            psrc_w >> 1,
                      &i420_buf[(psrc_w * psrc_h * 5) >> 2], psrc_w >> 1,
                      psrc_w, psrc_h,
                      &pdst_buf[0],                          pdst_w,
                      &pdst_buf[pdst_w * pdst_h],            pdst_w >> 1,
                      &pdst_buf[(pdst_w * pdst_h * 5) >> 2], pdst_w >> 1,
                      pdst_w, pdst_h,
                      filter);

    free(i420_buf);

    CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);

    NSUInteger dataLength = pdst_w * pdst_h * 3 >> 1;
    NSData *data = [NSData dataWithBytesNoCopy:pdst_buf length:dataLength];

    NSDictionary *frame = @{
        @"width": @(pdst_w),
        @"height": @(pdst_h),
        @"data": data,
        @"timestamp": @(CACurrentMediaTime() * 1000)
    };
    return frame;
}

屏幕分享主程序

  1. 初始化SDK,配置允许使用外部视频源,确保视频通话功能正常;
NERtcEngine *coreEngine = [NERtcEngine sharedEngine];
[coreEngine enableLocalAudio:YES];
[coreEngine enableLocalVideo:YES];
[coreEngine setExternalVideoSource:YES]; // 初始化SDK, 设置允许使用外部视频源
NERtcEngineContext *context = [[NERtcEngineContext alloc] init];
context.engineDelegate = self;
context.appKey = <#请输入您的AppKey#>;
[coreEngine setupEngineWithContext:context];
  1. 添加扩展程序相关
- (void)addSystemBroadcastPickerIfPossible
{
    if (@available(iOS 12.0, *)) {
        // Not recommend
        RPSystemBroadcastPickerView *picker = [[RPSystemBroadcastPickerView alloc] initWithFrame:CGRectMake(0, 0, 120, 64)];
        picker.showsMicrophoneButton = NO;
        picker.preferredExtension = <#扩展程序的BundleId#>;
        [self.view addSubview:picker];
        picker.center = self.view.center;

        UIButton *button = [picker.subviews filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id  _Nullable evaluatedObject, NSDictionary<NSString *,id> * _Nullable bindings) {
            return [evaluatedObject isKindOfClass:UIButton.class];
        }]].firstObject;
        [button setImage:nil forState:UIControlStateNormal];
        [button setTitle:@"Start Share" forState:UIControlStateNormal];
        [button setTitleColor:self.navigationController.navigationBar.tintColor forState:UIControlStateNormal];

        UIBarButtonItem *leftItem = [[UIBarButtonItem alloc] initWithCustomView:picker];
        self.navigationItem.leftBarButtonItem = leftItem;
    }
}
  1. 初始化同AppGroup名资料夹, 并添加监听事件。
- (void)setupUserDefaults
{
    // 通过UserDefaults建立数据通道,接收Extension传递来的视频帧
    self.userDefaults = [[NSUserDefaults alloc] initWithSuiteName:<#AppGroupName#>];
    [self.userDefaults addObserver:self forKeyPath:<#KeyPath#> options:NSKeyValueObservingOptionNew context:KVOContext];
}
  1. 监听到数据帧变化, 校验后推送外部视频帧到SDK
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
    if ([keyPath isEqualToString:<#KeyPath#>]) {
        if (self.currentUserID) {
            NSDictionary *i420Frame = change[NSKeyValueChangeNewKey];
            NERtcVideoFrame *frame = [[NERtcVideoFrame alloc] init];
            frame.format = kNERtcVideoFormatI420;
            frame.width = [i420Frame[@"width"] unsignedIntValue];
            frame.height = [i420Frame[@"height"] unsignedIntValue];
            frame.buffer = (void *)[i420Frame[@"data"] bytes];
            frame.timestamp = [i420Frame[@"timestamp"] unsignedLongLongValue];
            int ret = [NERtcEngine.sharedEngine pushExternalVideoFrame:frame]; // 推送外部视频帧到SDK
            if (ret != 0) {
                NSLog(@"发送视频流失败:%d", ret);
                return;
            }
        }
    }
}
  1. 不使用该功能时,记得移除观察者;
- (void)dealloc
{
    [self.userDefaults removeObserver:self forKeyPath:<#KeyPath#>];
    [NERtcEngine.sharedEngine leaveChannel];
}

API参考

方法 功能描述
setExternalVideoSource 配置外部视频源
pushExternalVideoFrame 推送外部视频帧

开发注意事项

  1. 主app和系统录屏需使用相同的AppGroup名;
  2. Sample工程支持iOS12及以上唤起系统录屏能力,若系统低于iOS12,需手动唤起系统录屏;

Windows/macOS

实现方法

在开始屏幕共享前,请确保已在你的项目中实现基本的实时音视频功能。

NERTC SDK 提供 startScreenCaptureByDisplayId、startScreenCaptureByWindowId、stopScreenCapture、pauseScreenCapture、resumeScreenCapture、updateScreenCaptureRegion 接口实现屏幕共享相关的功能。

大多数时候,应用程序不希望把屏幕分享者的操作界面分享到其他观看端,这种操作界面一般以浮动窗口的形式出现,比如悬浮在桌面的顶部,或者侧边。对于这种场景,SDK提供了排除窗口(或者叫过滤窗口)的功能。目前可以通过startScreenCaptureByScreenRect的参数nertc::NERtcScreenCaptureParameters来设置屏幕分享时要过滤的窗口。

示例代码(macOS)


// 开启屏幕共享(macOS)
int res = 0;
int max_profile_cur_sel = kNERtcVideoProfileHD720P;
nertc::NERtcScreenCaptureParameters capture_params;
capture_params.profile = nertc::kNERtcScreenProfileHD720P;

switch (max_profile_cur_sel)
{
    case kNERtcScreenProfile480P:
        capture_params.profile = nertc::kNERtcScreenProfile480P;
        capture_params.dimensions = { 640,480 };
        capture_params.frame_rate = 5;
        break;
    case kNERtcScreenProfileHD720P:
        capture_params.profile = nertc::kNERtcScreenProfileHD720P;
        capture_params.dimensions = { 1280,720 };
        capture_params.frame_rate = 5;
        break;
    case kNERtcScreenProfileHD1080P:
        capture_params.profile = nertc::kNERtcScreenProfileHD1080P;
        capture_params.dimensions = { 1920,1080 };
        capture_params.frame_rate = 5;
        break;
    case kNERtcScreenProfileCustom:
        capture_params.profile = nertc::kNERtcScreenProfileCustom;
        // 根据具体的交互逻辑,获取到自定义长宽
        capture_params.dimensions = { width,height };
        capture_params.frame_rate = 10;
    default:
        break;
}

capture_params.bitrate = 1500000;
capture_params.capture_mouse_cursor = true;
int errCode = nertc::kNERtcNoError;

// 首先创建一个nertc::IRtcEngineEx实例对象
nertc::IRtcEngineEx* nrtc_engine_ = ...;

// 是准备分享整个桌面(true),还是分享应用(false)
bool isDisplayShare = true;

if (isDisplayShare) {
    // 根据具体场景,获取到要排除的窗口id
    intptr_t excluded_wnd_id = ...; 
    capture_params.excluded_window_list = &excluded_wnd_id;
    capture_params.excluded_window_count = 1;

    errCode = nrtc_engine_->startScreenCaptureByDisplayId((uint32_t)windowId, region_rect, captureParam);
} else {
    errCode = nrtc_engine_->startScreenCaptureByWindowId((void *)&windowId, region_rect, captureParam);
}

// 暂停屏幕共享
nrtc_engine_->pauseScreenCapture();

// 恢复屏幕共享
nrtc_engine_->resumeScreenCapture();

// 更新取屏区域
nrtc_engine_->updateScreenCaptureRegion({ 0,0,640,480 });

// 停止屏幕共享
nrtc_engine_->stopScreenCapture();

对于那些基于Qt来开发音视频能力的开发者来说,要注意一点,Qt中通过 QWidget::winId() 得到的WId类型的值在不同平台上有一点差异。Qt在Mac下的实现这个返回值实际上是一个NSView对象指针(Windows平台上返回的是窗口的句柄HWND),而SDK接口需要的是NSWindow窗口的ID(成员 windowNumber)。解决这个问题可以参考下面的代码来通过WId获取Mac下窗口的ID:

/////// file: macx_helper.h
#ifndef HIDETITLEBAR_H
#define HIDETITLEBAR_H

#include <QQuickWindow>
#include <QScreen>
#include <QGuiApplication>

class MacXHelpers : public QObject
{
    Q_OBJECT
public:
    MacXHelpers() {}

public slots:
    int getWindowId(WId wid);
};

#endif // HIDETITLEBAR_H

/////// file: macx_helper.mm
#include "macx_helpers.h"

#import <AppKit/AppKit.h>

int MacXHelpers::getWindowId(WId wid)
{
    NSView *nativeView = reinterpret_cast<NSView *>(wid);
    NSWindow* nativeWindow = nativeView.window;
    if (nativeWindow)
    {
        return nativeWindow.windowNumber;
    }

    return 0;
}

示例代码(Windows)

// 开启屏幕共享(Windows)
int res = 0;
HWND hwnd = GetSelectCaptureWindow();
if ((!IsWindow(hwnd) || !IsWindowVisible(hwnd) || IsIconic(hwnd)) && hwnd != NULL) {
    ShowLogWarning("the window has been destroyed, hide, or minimized, please select a window again.");
    RefreshCaptureWindow();
    return;
}
int fps = 5;
int max_profile_cur_sel = (int)SendDlgItemMessage(m_hWnd, IDC_CaptureProfile, CB_GETCURSEL, 0, 0);
nertc::NERtcScreenCaptureParameters capture_params;
capture_params.profile = nertc::kNERtcScreenProfileHD720P;
switch (max_profile_cur_sel)
{
    case kNERtcScreenProfile480P:
        capture_params.profile = nertc::kNERtcScreenProfile480P;
        capture_params.dimensions = { 640,480 };
        fps = 5;
        break;
    case kNERtcScreenProfileHD720P:
        capture_params.profile = nertc::kNERtcScreenProfileHD720P;
        capture_params.dimensions = { 1280,720 };
        fps = 5;
        break;
    case kNERtcScreenProfileHD1080P:
        capture_params.profile = nertc::kNERtcScreenProfileHD1080P;
        capture_params.dimensions = { 1920,1080 };
        fps = 5;
        break;
    case kNERtcScreenProfileCustom:
    {
        capture_params.profile = nertc::kNERtcScreenProfileCustom;
        int w = GetDlgItemInt(m_hWnd, IDC_Capture_Width, NULL, FALSE);
        if (w <= 0) {
            return;
        }
        int h = GetDlgItemInt(m_hWnd, IDC_Capture_Height, NULL, FALSE);
        if (h <= 0) {
            return;
        }
        capture_params.dimensions = { w,h };
        fps = GetDlgItemInt(m_hWnd, IDC_Capture_Fps, NULL, FALSE);
        if (fps <= 0) {
            return;
        }
    }
        break;
    default:
        break;
}

capture_params.frame_rate = fps;
int bps = 0;
GetDlgItemInt(m_hWnd, IDC_SCREEN_BPS, &bps, FALSE);
capture_params.bitrate = bps;
capture_params.capture_mouse_cursor = true;
capture_params.window_focus = false;
capture_params.excluded_window_list = nullptr;
capture_params.excluded_window_count = 0;

if (hwnd == nullptr) {
    // 根据具体场景,设置要排除窗口的句柄
    HWND* wnd_list = new HWND[exclude_wnd_list_.size()];
    int index = 0;
    for (auto e : exclude_wnd_list_) {
        *(wnd_list + capture_params.excluded_window_count++) = e;
    }
    capture_params.excluded_window_list = wnd_list;

    res = nrtc_engine_->StartScreenCapturerByScreenRect({ 0,0,0,0 }, { 0,0,0,0 }, capture_params);

    // 及时释放掉申请的内存
    delete[] wnd_list;
    wnd_list = nullptr;
} else {
    res = nrtc_engine_->StartScreenCapturerByWindowId(hwnd, { 0,0,0,0 }, capture_params);
}

// 暂停屏幕共享
nrtc_engine_->pauseScreenCapture();

// 恢复屏幕共享
nrtc_engine_->resumeScreenCapture();

// 更新取屏区域
nrtc_engine_->updateScreenCaptureRegion({ 0,0,640,480 });

// 停止屏幕共享
nrtc_engine_->stopScreenCapture();

API参考

方法 功能描述
startScreenCaptureByScreenRect 开启屏幕共享(Windows)
startScreenCaptureByDisplayId 开启屏幕共享(MacOS)
startScreenCaptureByWindowId 通过窗口 ID 共享窗口
stopScreenCapture 停止屏幕共享
pauseScreenCapture 暂停屏幕共享
resumeScreenCapture 恢复屏幕共享

开发注意事项

所有start对应同一个stop,同时只允许开启一个数据源。

Web

实现方法

在开始屏幕共享前,请确保已在你的项目中实现基本的实时音视频功能。

初始化时开启屏幕共享

在 Chrome 上屏幕共享可以直接在 createStream 时把 video 字段设为 false, screen 字段设为 true 即可

//创建local的时候,设置screen为ture
let localStream = NRTC.createStream({
  uid: uid,
  audio: true,
  video: false,
  screen: true,
})

localStream
.init()
.catch(error => {
  console.error('初始化本地流失败 ' + error);
})
.then(() => {
  console.log('初始化本地流成功');
});

Note:

这样就可以初始化的时候,开启了屏幕共享,注意此时没有摄像头。

通话中途开启屏幕共享

一开始 createStream 时把 video 字段设为 true, screen 字段设为 false,先打开了摄像头,但是中途如果想开启屏幕共享,可以调用如下接口:

localStream.close({
  type: 'video'
}).then(()=>{
  console.log('关闭摄像头 sucess')

    let config = {
      type: 'screen', // video:摄像头,screen:屏幕共享,audio: 麦克风
    }
    localStream.open(config).then(()=>{
      //打开屏幕共享成功
    }).catchn(err=>{
      //打开屏幕共享失败
    })

})

Note:

先调用 close接口关闭摄像头,在开启屏幕共享。

chrome 72版本之后才可以使用上述方法进行屏幕共享。

设置屏幕共享属性

在开启屏幕之前可以设置相关属性,可以根据用户喜好,调整屏幕共享画面的清晰度,获得较高的用户体验(在调用localStream.init() 或者 localStream.open() 接口之前调用)

let resolution = WebRTC2.VIDEO_QUALITY_1080p
localStream.setScreenProfile(resolution)
参数名 类型 说明
resolution Number 设置的屏幕共享分辨率

resolution, 视频分辨率设置

resolution可选值 类型 说明
WebRTC2.VIDEO_QUALITY_480p number 屏幕共享低分辨率 640x480
WebRTC2.VIDEO_QUALITY_720p number 屏幕共享中分辨率 1280x720
WebRTC2.VIDEO_QUALITY_1080p number 屏幕共享高分辨率 1920x1080