你当前正在访问 Microsoft Azure Global Edition 技术文档网站。 如果需要访问由世纪互联运营的 Microsoft Azure 中国技术文档网站,请访问 https://docs.azure.cn

在通话期间管理视频

了解如何使用 Azure 通信服务 SDK 管理视频通话。 我们将了解如何在通话中管理接收和发送的视频。

先决条件

安装 SDK

使用 npm install 命令安装适用于 JavaScript 的 Azure 通信服务通用 SDK 和通话 SDK:

npm install @azure/communication-common --save
npm install @azure/communication-calling --save

初始化所需的对象

大多数通话操作需要 CallClient 实例。 创建新的 CallClient 实例时,可以使用自定义选项(如 Logger 实例)对其进行配置。

有了 CallClient 实例后,可以通过调用 createCallAgent 创建 CallAgent 实例。 此方法将异步返回 CallAgent 实例对象。

createCallAgent 方法使用 CommunicationTokenCredential 作为参数。 它接受用户访问令牌

可在 CallClient 实例上使用 getDeviceManager 方法来访问 deviceManager

const { CallClient } = require('@azure/communication-calling');
const { AzureCommunicationTokenCredential} = require('@azure/communication-common');
const { AzureLogger, setLogLevel } = require("@azure/logger");

// Set the logger's log level
setLogLevel('verbose');

// Redirect log output to console, file, buffer, REST API, or whatever location you want
AzureLogger.log = (...args) => {
    console.log(...args); // Redirect log output to console
};

const userToken = '<USER_TOKEN>';
callClient = new CallClient(options);
const tokenCredential = new AzureCommunicationTokenCredential(userToken);
const callAgent = await callClient.createCallAgent(tokenCredential, {displayName: 'optional Azure Communication Services user name'});
const deviceManager = await callClient.getDeviceManager()

如何最好地管理 SDK 与 Microsoft 基础结构的连接性

Call Agent 实例可帮助你管理通话(以加入或启动通话)。 通话 SDK 需要连接到 Microsoft 基础结构以获取传入通话通知并协调其他通话详细信息,否则无法工作。 你的 Call Agent 有两种可能的状态:

已连接 - Call Agent connectionStatue 值为 Connected 表示客户端 SDK 已连接,能够接收来自 Microsoft 基础结构的通知。

已断开连接 - Call Agent connectionStatue 值为 Disconnected 表示存在阻止 SDK 正确连接的问题。 应重新创建 Call Agent

  • invalidToken:如果令牌已过期或无效,Call Agent 实例会断开连接并出现此错误。
  • connectionIssue:如果客户端连接到 Microsoft 基础结构时出现问题,则在多次重试后,Call Agent 会显示 connectionIssue 错误。

可以通过检查 connectionState 属性的当前值来检查本地 Call Agent 是否已连接到 Microsoft 基础结构。 在通话过程中,可以侦听 connectionStateChanged 事件,以确定 Call Agent 是否从“已连接”状态更改为“已断开连接”状态。

const connectionState = callAgentInstance.connectionState;
console.log(connectionState); // it may return either of 'Connected' | 'Disconnected'

const connectionStateCallback = (args) => {
    console.log(args); // it will return an object with oldState and newState, each of having a value of either of 'Connected' | 'Disconnected'
    // it will also return reason, either of 'invalidToken' | 'connectionIssue'
}
callAgentInstance.on('connectionStateChanged', connectionStateCallback);

设备管理

若要开始通过通话 SDK 使用视频通话,你需要能够管理设备。 使用设备可以控制将音频和视频传输到通话。

借助 deviceManager,你可以枚举可在通话中传输音频和视频流的本地设备。 还可使用 deviceManager 来请求访问本地设备的麦克风和相机的权限。

可以调用 callClient.getDeviceManager() 方法来访问 deviceManager

const deviceManager = await callClient.getDeviceManager();

获取本地设备

若要访问本地设备,可以使用 deviceManager 枚举方法 getCameras()getMicrophones。 这些方法是异步操作。

//  Get a list of available video devices for use.
const localCameras = await deviceManager.getCameras(); // [VideoDeviceInfo, VideoDeviceInfo...]

// Get a list of available microphone devices for use.
const localMicrophones = await deviceManager.getMicrophones(); // [AudioDeviceInfo, AudioDeviceInfo...]

// Get a list of available speaker devices for use.
const localSpeakers = await deviceManager.getSpeakers(); // [AudioDeviceInfo, AudioDeviceInfo...]

设置默认设备

知道哪些设备可供使用后,可以为麦克风、扬声器和相机设置默认设备。 如果未设置客户端默认项,通信服务 SDK 将使用操作系统默认项。

Microphone

访问使用的设备

// Get the microphone device that is being used.
const defaultMicrophone = deviceManager.selectedMicrophone;

设置要使用的设备

// Set the microphone device to use.
await deviceManager.selectMicrophone(localMicrophones[0]);

主讲人

访问使用的设备

// Get the speaker device that is being used.
const defaultSpeaker = deviceManager.selectedSpeaker;

设置要使用的设备

// Set the speaker device to use.
await deviceManager.selectSpeaker(localSpeakers[0]);

Camera

访问使用的设备

// Get the camera device that is being used.
const defaultSpeaker = deviceManager.selectedSpeaker;

设置要使用的设备

// Set the speaker device to use.
await deviceManager.selectSpeaker(localCameras[0]);

每个 CallAgent 都可以在其关联的 DeviceManager 上选择自己的麦克风和扬声器。 我们建议不同的 CallAgents 使用不同的麦克风和扬声器。 它们不应共享相同的麦克风和扬声器。 如果发生共享,则可能会触发面向用户的麦克风诊断,导致麦克风停止工作(具体取决于所用的浏览器/操作系统)。

本地视频流

若要能够在通话中发送视频,需要创建 LocalVideoStream 对象。

const localVideoStream = new LocalVideoStream(camera);

作为参数传递的相机是 deviceManager.getCameras() 方法返回的 VideoDeviceInfo 对象之一。

LocalVideoStream 具有以下属性:

  • source:设备信息。
const source = localVideoStream.source;
  • mediaStreamType:可以是 VideoScreenSharingRawMedia
const type: MediaStreamType = localVideoStream.mediaStreamType;

本地相机预览

可使用 deviceManagerVideoStreamRenderer 开始呈现来自本地相机的流。 创建 LocalVideoStream 后,即可将其用于设置 VideoStreamRenderer。 创建 VideoStreamRenderer 后,请调用其 createView() 方法,以获取可以作为子级添加到页面的视图。

此流不会发送给其他参与者;它是一个本地预览源。

// To start viewing local camera preview
const cameras = await deviceManager.getCameras();
const camera = cameras[0];
const localVideoStream = new LocalVideoStream(camera);
const videoStreamRenderer = new VideoStreamRenderer(localVideoStream);
const view = await videoStreamRenderer.createView();
htmlElement.appendChild(view.target);

停止本地预览

若要停止本地预览通话,请处置派生自 VideoStreamRenderer 的视图。 处置 VideoStreamRenderer 后,请通过从包含预览的 DOM 节点调用 removeChild() 方法来从 html 树中移除视图。

// To stop viewing local camera preview
view.dispose();
htmlElement.removeChild(view.target);

请求对相机和麦克风的权限

应用程序无法在没有权限的情况下使用相机或麦克风。 你可以使用 deviceManager 提示用户授予相机和/或麦克风权限:

const result = await deviceManager.askDevicePermission({audio: true, video: true});

解析承诺后,该方法将返回一个 DeviceAccess 对象,来指示是否授予了 audiovideo 权限:

console.log(result.audio);
console.log(result.video);

说明

  • 插入/拔出视频设备时触发videoDevicesUpdated事件。
  • 插入音频设备时触发audioDevicesUpdated事件。
  • 创建 DeviceManager 后,如果尚未授予权限,则它一开始不知道任何设备,因此最初其设备名为空,并且不包含详细的设备信息。 如果随后调用 DeviceManager.askPermission () API,系统会提示用户进行设备访问。 当用户选择“允许”来授予设备管理器了解系统上的设备的访问权限时,请更新其设备列表并发出“audioDevicesUpdated”和“videoDevicesUpdated”事件。 如果用户刷新页面并创建设备管理器,则设备管理器能够了解设备,因为用户以前已授予访问权限。 它最初填充了其设备列表,但不会发出“audioDevicesUpdated”和“videoDevicesUpdated”事件。
  • Android Chrome、iOS Safari 和 macOS Safari 都不支持扬声器枚举/选择项。

发起与摄像机的通话

重要

目前仅支持一个传出本地视频流。

若要发起视频通话,必须使用 deviceManager 中的 getCameras() 方法枚举本地相机。

选择相机后,请使用它来构造 LocalVideoStream 实例。 在 videoOptions 中将其作为 localVideoStream 数组中的项传递到 CallAgent startCall 方法。

const deviceManager = await callClient.getDeviceManager();
const cameras = await deviceManager.getCameras();
const camera = cameras[0]
const localVideoStream = new LocalVideoStream(camera);
const placeCallOptions = {videoOptions: {localVideoStreams:[localVideoStream]}};
const userCallee = { communicationUserId: '<ACS_USER_ID>' }
const call = callAgent.startCall([userCallee], placeCallOptions);
  • 还可以使用 CallAgent.join() API 加入视频通话,并使用 Call.Accept() API 接受和进行视频通话。
  • 在通话接通后,它就会自动将来自所选相机的视频流发送给其他参与者。

通话时开始和停止发送本地视频

打开视频

若要在通话时开始视频,必须在 deviceManager 对象上使用 getCameras 方法来枚举相机。 接下来使用所需相机创建一个新的 LocalVideoStream 实例,然后将 LocalVideoStream 对象传递给现有通话对象的 startVideo 方法:

const deviceManager = await callClient.getDeviceManager();
const cameras = await deviceManager.getCameras();
const camera = cameras[0]
const localVideoStream = new LocalVideoStream(camera);
await call.startVideo(localVideoStream);

停止视频

成功开始发送视频后,Video 类型的 LocalVideoStream 实例会添加到呼叫实例上的 localVideoStreams 集合中。

在 Call 对象中查找视频流

const localVideoStream = call.localVideoStreams.find( (stream) => { return stream.mediaStreamType === 'Video'} );

停止本地视频:若要在通话中停止本地视频,请将用于视频的 localVideoStream 实例传递给 Call 的 stopVideo 方法:

await call.stopVideo(localVideoStream);

通过在该 LocalVideoStream 实例上调用 switchSource,可以在具有活动 LocalVideoStream 的同时切换到其他相机设备:

const cameras = await callClient.getDeviceManager().getCameras();
const camera = cameras[1];
localVideoStream.switchSource(camera);

如果指定的视频设备不可用:

  • 在通话中,如果视频已关闭并且你开始使用call.startVideo()进行视频,此方法将引发 SourceUnavailableError,且 cameraStartFailed 面向用户的诊断设置为 true。
  • 调用 localVideoStream.switchSource() 方法将导致 cameraStartFailed 设置为 true。 我们的通话诊断指南提供有关如何诊断通话相关问题的其他信息。

若要验证本地视频是启用还是禁用状态,可以使用 Call 方法 isLocalVideoStarted,该方法会返回 true 或 false:

// Check if local video is on or off
call.isLocalVideoStarted;

要监听本地视频的变化,可以订阅和取消订阅 isLocalVideoStartedChanged 事件:

// Subscribe to local video event
call.on('isLocalVideoStartedChanged', () => {
    // Callback();
});
// Unsubscribe from local video event
call.off('isLocalVideoStartedChanged', () => {
    // Callback();
});

通话时开始和停止屏幕共享

若要在通话中开始屏幕共享,可以在 Call 对象上使用异步方法 startScreenSharing()

开始屏幕共享

// Start screen sharing
await call.startScreenSharing();

注意:仅桌面浏览器支持发送屏幕共享。

在 LocalVideoStream 集合中查找屏幕共享

成功开始发送屏幕共享后,ScreenSharing 类型的 LocalVideoStream 实例将会添加到通话实例上的 localVideoStreams 集合。

const localVideoStream = call.localVideoStreams.find( (stream) => { return stream.mediaStreamType === 'ScreenSharing'} );

停止屏幕共享

若要在通话中停止屏幕共享,可以使用异步 API stoptScreenSharing:

// Stop screen sharing
await call.stopScreenSharing();

检查屏幕共享状态

要验证屏幕共享是打开还是关闭,可以使用 isScreenSharingOn API,此 API 返回 true 或 false:

// Check if screen sharing is on or off
call.isScreenSharingOn;

要监听屏幕共享的更改,可以订阅和取消订阅 isScreenSharingOnChanged 事件:

// Subscribe to screen share event
call.on('isScreenSharingOnChanged', () => {
    // Callback();
});
// Unsubscribe from screen share event
call.off('isScreenSharingOnChanged', () => {
    // Callback();
});

重要

Azure 通信服务的这一功能目前以预览版提供。

预览版 API 和 SDK 在没有服务级别协议的情况下提供。 建议不要将它们用于生产工作负荷。 某些功能可能不受支持或者已受限。

有关详细信息,请参阅 Microsoft Azure 预览版补充使用条款

本地屏幕共享预览提供公共预览版,并在版本 1.15.1-beta.1+ 中提供。

本地屏幕共享预览

你可以使用 VideoStreamRenderer 开始通过本地屏幕共享来呈现流,以便查看作为屏幕共享流发送的内容。

// To start viewing local screen share preview
await call.startScreenSharing();
const localScreenSharingStream = call.localVideoStreams.find( (stream) => { return stream.mediaStreamType === 'ScreenSharing' });
const videoStreamRenderer = new VideoStreamRenderer(localScreenSharingStream);
const view = await videoStreamRenderer.createView();
htmlElement.appendChild(view.target);

// To stop viewing local screen share preview.
await call.stopScreenSharing();
view.dispose();
htmlElement.removeChild(view.target);

// Screen sharing can also be stoped by clicking on the native browser's "Stop sharing" button.
// The isScreenSharingOnChanged event will be triggered where you can check the value of call.isScreenSharingOn.
// If the value is false, then that means screen sharing is turned off and so we can go ahead and dispose the screen share preview.
// This event is also triggered for the case when stopping screen sharing via Call.stopScreenSharing() API.
call.on('isScreenSharingOnChanged', () => {
    if (!call.isScreenSharingOn) {
        view.dispose();
        htmlElement.removeChild(view.target);
    }
});

呈现远程参与者视频/屏幕共享流

若要呈现远程参与者视频或屏幕共享,第一步是获取要呈现的 RemoteVideoStream 的引用。 这可以通过遍历 RemoteParticipant 的数组或视频流 (videoStreams) 来完成。 通过 Call 对象,可访问远程参与者集合。

const remoteVideoStream = call.remoteParticipants[0].videoStreams[0];
const streamType = remoteVideoStream.mediaStreamType;

若要呈现 RemoteVideoStream,你必须订阅它的 isAvailableChanged 事件。 如果 isAvailable 属性更改为 true,则远程参与者正在发送视频流。 发生该情况后,请创建一个新的 VideoStreamRenderer 实例,然后使用异步 createView 方法创建一个新的 VideoStreamRendererView 实例。
然后,可以将 view.target 附加到任何 UI 元素。

只要远程流的可用性发生更改,就可以销毁整个 VideoStreamRenderer 或特定 VideoStreamRendererView。 如果你决定保留它们,则视图将显示一个空白的视频帧。

// Reference to the html's div where we would display a grid of all remote video stream from all participants.
let remoteVideosGallery = document.getElementById('remoteVideosGallery');

subscribeToRemoteVideoStream = async (remoteVideoStream) => {
    let renderer = new VideoStreamRenderer(remoteVideoStream);
    let view;
    let remoteVideoContainer = document.createElement('div');
    remoteVideoContainer.className = 'remote-video-container';

    let loadingSpinner = document.createElement('div');
    // See the css example below for styling the loading spinner.
    loadingSpinner.className = 'loading-spinner';
    remoteVideoStream.on('isReceivingChanged', () => {
        try {
            if (remoteVideoStream.isAvailable) {
                const isReceiving = remoteVideoStream.isReceiving;
                const isLoadingSpinnerActive = remoteVideoContainer.contains(loadingSpinner);
                if (!isReceiving && !isLoadingSpinnerActive) {
                    remoteVideoContainer.appendChild(loadingSpinner);
                } else if (isReceiving && isLoadingSpinnerActive) {
                    remoteVideoContainer.removeChild(loadingSpinner);
                }
            }
        } catch (e) {
            console.error(e);
        }
    });

    const createView = async () => {
        // Create a renderer view for the remote video stream.
        view = await renderer.createView();
        // Attach the renderer view to the UI.
        remoteVideoContainer.appendChild(view.target);
        remoteVideosGallery.appendChild(remoteVideoContainer);
    }

    // Remote participant has switched video on/off
    remoteVideoStream.on('isAvailableChanged', async () => {
        try {
            if (remoteVideoStream.isAvailable) {
                await createView();
            } else {
                view.dispose();
                remoteVideosGallery.removeChild(remoteVideoContainer);
            }
        } catch (e) {
            console.error(e);
        }
    });

    // Remote participant has video on initially.
    if (remoteVideoStream.isAvailable) {
        try {
            await createView();
        } catch (e) {
            console.error(e);
        }
    }
    
    console.log(`Initial stream size: height: ${remoteVideoStream.size.height}, width: ${remoteVideoStream.size.width}`);
    remoteVideoStream.on('sizeChanged', () => {
        console.log(`Remote video stream size changed: new height: ${remoteVideoStream.size.height}, new width: ${remoteVideoStream.size.width}`);
    });
}

用于为远程视频流设置“正在加载”旋转图标样式的 CSS。

.remote-video-container {
   position: relative;
}
.loading-spinner {
   border: 12px solid #f3f3f3;
   border-radius: 50%;
   border-top: 12px solid #ca5010;
   width: 100px;
   height: 100px;
   -webkit-animation: spin 2s linear infinite; /* Safari */
   animation: spin 2s linear infinite;
   position: absolute;
   margin: auto;
   top: 0;
   bottom: 0;
   left: 0;
   right: 0;
   transform: translate(-50%, -50%);
}
@keyframes spin {
   0% { transform: rotate(0deg); }
   100% { transform: rotate(360deg); }
}
/* Safari */
@-webkit-keyframes spin {
   0% { -webkit-transform: rotate(0deg); }
   100% { -webkit-transform: rotate(360deg); }
}

远程视频质量

从版本 1.15.1 开始,Azure 通信服务 WebJS SDK 提供了一个名为“最佳视频计数”(OVC) 的功能。 此功能可用于在运行时告诉应用程序:在给定时刻可以在群组通话(2 名以上参与者)中以最佳方式呈现多少个来自不同参与者的传入视频。 此功能公开一个属性 optimalVideoCount,该属性会根据本地终结点的网络和硬件功能在调用期间动态更改。 optimalVideoCount 的值详细说明了应用程序应在给定时间呈现的来自不同参与者的视频数。 应用程序应根据建议处理这些更改并更新呈现的视频数。 每次更新之间都有一个抖动期(大约 10 秒)。

用法optimalVideoCount 功能是一个通话功能。 你需要通过 Call 对象的 feature 方法引用功能 OptimalVideoCount。 然后,可以通过 OptimalVideoCountCallFeatureon 方法设置侦听器,以在 optimalVideoCount 发生变化时收到通知。 若要取消订阅此类变化,可以调用 off 方法。 当前可呈现的传入视频的最大数量为 16。 为了正确支持 16 个传入视频,计算机应至少有 16GB RAM 以及一个至少 4 核且使用时间不超过 3 年的 CPU。

const optimalVideoCountFeature = call.feature(Features.OptimalVideoCount);
optimalVideoCountFeature.on('optimalVideoCountChanged', () => {
    const localOptimalVideoCountVariable = optimalVideoCountFeature.optimalVideoCount;
})

示例用法:应用程序应订阅群组通话中最佳视频计数的变化。 可以通过创建新的呈现器(createView 方法),或者处置视图 (dispose) 并相应地更新应用程序布局来处理最佳视频计数的变化。

远程视频流属性

远程视频流具有以下属性:

const id: number = remoteVideoStream.id;
  • id:远程视频流的 ID。
const type: MediaStreamType = remoteVideoStream.mediaStreamType;
  • mediaStreamType:可以是 VideoScreenSharing
const isAvailable: boolean = remoteVideoStream.isAvailable;
  • isAvailable:定义远程参与者终结点是否正在主动发送流。
const isReceiving: boolean = remoteVideoStream.isReceiving;
  • isReceiving设置用户帐户 :
    • 告知应用程序是否收到了远程视频流数据。

    • 在以下情况下,标志将变为 false

      • 使用移动浏览器的远程参与者将浏览器应用引入了后台。
      • 接收视频的远程参与者或用户有影响视频质量的网络问题。
      • 使用 macOS/iOS Safari 的远程参与者从地址栏中选择了“暂停”。
      • 远程参与者的网络断开了连接。
      • 使用移动设备的远程参与者终止了浏览器的运行。
      • 使用移动或桌面设备的远程参与者锁定了其设备。 如果远程参与者使用的是台式机并且进入了睡眠状态,则也属于这种情况。
    • 在以下情况下,标志将变为 true

      • 使用移动浏览器的远程参与者将其原来在后台运行的浏览器引入了前台。
      • 使用 macOS/iOS Safari 的远程参与者在暂停视频后从地址栏中选择了“恢复”。
      • 远程参与者在临时断开连接后重新连接到了网络。
      • 使用移动设备的远程参与者解锁了其设备并返回到其在移动浏览器上的通话。
    • 此功能改进了用于呈现远程视频流的用户体验。

    • 将 isReceiving 标志更改为 false 时,可以在远程视频流上显示“正在加载”旋转图标。 你不一定非要实现加载旋转图标,但为了提供更好的用户体验,加载旋转图标是最常见的用法。

const size: StreamSize = remoteVideoStream.size;
  • size:流大小,其中包含有关视频宽度和高度的信息。

VideoStreamRenderer 方法和属性

await videoStreamRenderer.createView();

创建一个 VideoStreamRendererView 实例,该实例可以附加到应用程序 UI 中来呈现远程视频流;使用异步 createView() 方法,该方法会在流准备好呈现时进行解析,并返回一个具有 target 属性的对象(该对象表示可以插入到 DOM 树中的任何位置的 video 元素)。

videoStreamRenderer.dispose();

处置 videoStreamRenderer 和所有关联的 VideoStreamRendererView 实例。

VideoStreamRendererView 方法和属性

创建 VideoStreamRendererView 时,你可以指定 scalingModeisMirrored 属性。 scalingMode 可以是 StretchCropFit。 如果指定了 isMirrored,则呈现的流会垂直翻转。

const videoStreamRendererView: VideoStreamRendererView = await videoStreamRenderer.createView({ scalingMode, isMirrored });

每个 VideoStreamRendererView 实例都有一个表示呈现图面的 target 属性。 在应用程序 UI 中附加此属性:

htmlElement.appendChild(view.target);

可以调用 updateScalingMode 方法来更新 scalingMode

view.updateScalingMode('Crop');

从两个不同的相机发送视频流,在同一次来自同一桌面设备的通话中。

重要

Azure 通信服务的这一功能目前以预览版提供。

预览版 API 和 SDK 在没有服务级别协议的情况下提供。 建议不要将它们用于生产工作负荷。 某些功能可能不受支持或者已受限。

有关详细信息,请参阅 Microsoft Azure 预览版补充使用条款

在桌面设备支持的浏览器上,版本 1.17.1-beta.1+ 支持在同一通话中发送来自两个不同相机的视频流。

  • 可以在同一调用中从单个桌面浏览器选项卡/应用中从两个不同的相机发送视频流,并使用以下代码片段:
// Create your first CallAgent with identity A
const callClient1 = new CallClient();
const callAgent1 = await callClient1.createCallAgent(tokenCredentialA);
const deviceManager1 = await callClient1.getDeviceManager();

// Create your second CallAgent with identity B
const callClient2 = new CallClient();
const callAgent2 = await callClient2.createCallAgent(tokenCredentialB);
const deviceManager2 = await callClient2.getDeviceManager();

// Join the call with your first CallAgent
const camera1 = await deviceManager1.getCameras()[0];
const callObj1 = callAgent1.join({ groupId: ‘123’}, { videoOptions: { localVideoStreams: [new LocalVideoStream(camera1)] } });

// Join the same call with your second CallAgent and make it use a different camera
const camera2 = (await deviceManager2.getCameras()).filter((camera) => { return camera !== camera1 })[0];
const callObj2 = callAgent2.join({ groupId: '123' }, { videoOptions: { localVideoStreams: [new LocalVideoStream(camera2)] } });

//Mute the microphone and speakers of your second CallAgent’s Call, so that there is no echos/noises.
await callObj2.muteIncomingAudio();
await callObj2.mute();

限制:

  • 这必须使用不同的标识通过两个不同的 CallAgent 实例实现。 该代码片段显示了正在使用的两个通话代理,每个代理都有其自己的 Call 对象。
  • 在代码示例中,两个 CallAgent 都联接同一个调用(同一个呼叫 ID)。 还可以与每个代理加入不同的呼叫,并在一次呼叫上发送一个视频,另一个呼叫上的另一个视频。
  • 不支持在两个 CallAgent 中发送同一相机。 它们必须是两个不同的相机。
  • 目前不支持使用一个 CallAgent 发送两个不同的相机。
  • 在 macOS Safari 上,背景模糊视频效果(来自 @azure/communication-effects),只能应用于一个相机,不能同时应用两者)。

安装 SDK

找到项目级 build.gradle 文件,并将 mavenCentral() 添加到 buildscriptallprojects 下的存储库列表中:

buildscript {
    repositories {
    ...
        mavenCentral()
    ...
    }
}
allprojects {
    repositories {
    ...
        mavenCentral()
    ...
    }
}

然后,在模块级 build.gradle 文件中,将以下行添加到 dependencies 部分:

dependencies {
    ...
    implementation 'com.azure.android:azure-communication-calling:1.0.0'
    ...
}

初始化所需的对象

若要创建 CallAgent 实例,必须对 CallClient 实例调用 createCallAgent 方法。 此调用将异步返回 CallAgent 实例对象。

createCallAgent 方法采用 CommunicationUserCredential 作为参数来封装访问令牌

若要访问 DeviceManager,必须先创建 callAgent 实例。 然后,可以使用 CallClient.getDeviceManager 方法获取 DeviceManager

String userToken = '<user token>';
CallClient callClient = new CallClient();
CommunicationTokenCredential tokenCredential = new CommunicationTokenCredential(userToken);
android.content.Context appContext = this.getApplicationContext(); // From within an activity, for instance
CallAgent callAgent = callClient.createCallAgent(appContext, tokenCredential).get();
DeviceManager deviceManager = callClient.getDeviceManager(appContext).get();

若要为主叫方设置显示名称,请使用以下替代方法:

String userToken = '<user token>';
CallClient callClient = new CallClient();
CommunicationTokenCredential tokenCredential = new CommunicationTokenCredential(userToken);
android.content.Context appContext = this.getApplicationContext(); // From within an activity, for instance
CallAgentOptions callAgentOptions = new CallAgentOptions();
callAgentOptions.setDisplayName("Alice Bob");
DeviceManager deviceManager = callClient.getDeviceManager(appContext).get();
CallAgent callAgent = callClient.createCallAgent(appContext, tokenCredential, callAgentOptions).get();

设备管理

若要开始将视频与通话一起使用,需要知道如何管理设备。 使用设备可以控制将音频和视频传输到通话。

借助 DeviceManager,可枚举能够在通话中用于传输音频/视频流的本地设备。 你还可用它来通过本机浏览器 API 向用户请求访问其麦克风和相机的权限。

可调用 callClient.getDeviceManager() 方法来访问 deviceManager

Context appContext = this.getApplicationContext();
DeviceManager deviceManager = callClient.getDeviceManager(appContext).get();

枚举本地设备

若要访问本地设备,可在设备管理器上使用枚举方法。 枚举是同步操作。

//  Get a list of available video devices for use.
List<VideoDeviceInfo> localCameras = deviceManager.getCameras(); // [VideoDeviceInfo, VideoDeviceInfo...]

本地相机预览

可使用 DeviceManagerRenderer 开始呈现来自本地相机的流。 此流不会发送给其他参与者;这是一项本地预览源。 这是异步操作。

VideoDeviceInfo videoDevice = <get-video-device>; // See the `Enumerate local devices` topic above
Context appContext = this.getApplicationContext();

LocalVideoStream currentVideoStream = new LocalVideoStream(videoDevice, appContext);

LocalVideoStream[] localVideoStreams = new LocalVideoStream[1];
localVideoStreams[0] = currentVideoStream;

VideoOptions videoOptions = new VideoOptions(localVideoStreams);

RenderingOptions renderingOptions = new RenderingOptions(ScalingMode.Fit);
VideoStreamRenderer previewRenderer = new VideoStreamRenderer(currentVideoStream, appContext);

VideoStreamRendererView uiView = previewRenderer.createView(renderingOptions);

// Attach the uiView to a viewable location on the app at this point
layout.addView(uiView);

发起启用相机的一对一通话

警告

目前仅支持一个传出本地视频流。若要发出带有视频的呼叫,必须使用 deviceManager getCameras API 枚举本地相机。 选择所需相机后,用它来构造一个 LocalVideoStream 实例,并将其作为 call 方法的 localVideoStream 数组中的项目传递给 videoOptions。 呼叫接通后,会自动开始将视频流从所选相机发送给其他的参与者。

注意

出于隐私考虑,如果未在本地预览视频,则不在呼叫中共享该视频。 有关更多详细信息,请查看本地相机预览

VideoDeviceInfo desiredCamera = <get-video-device>; // See the `Enumerate local devices` topic above
Context appContext = this.getApplicationContext();

LocalVideoStream currentVideoStream = new LocalVideoStream(desiredCamera, appContext);

LocalVideoStream[] localVideoStreams = new LocalVideoStream[1];
localVideoStreams[0] = currentVideoStream;

VideoOptions videoOptions = new VideoOptions(localVideoStreams);

// Render a local preview of video so the user knows that their video is being shared
Renderer previewRenderer = new VideoStreamRenderer(currentVideoStream, appContext);
View uiView = previewRenderer.createView(new CreateViewOptions(ScalingMode.FIT));

// Attach the uiView to a viewable location on the app at this point
layout.addView(uiView);

CommunicationUserIdentifier[] participants = new CommunicationUserIdentifier[]{ new CommunicationUserIdentifier("<acs user id>") };

StartCallOptions startCallOptions = new StartCallOptions();
startCallOptions.setVideoOptions(videoOptions);

Call call = callAgent.startCall(context, participants, startCallOptions);

开始和停止发送本地视频

若要开始发送视频,必须在 deviceManager 对象上使用 getCameraList API 来枚举相机。 然后,创建 LocalVideoStream 的新实例传递所需的相机,并将其作为参数传递给 startVideo API:

VideoDeviceInfo desiredCamera = <get-video-device>; // See the `Enumerate local devices` topic above
Context appContext = this.getApplicationContext();

LocalVideoStream currentLocalVideoStream = new LocalVideoStream(desiredCamera, appContext);

VideoOptions videoOptions = new VideoOptions(currentLocalVideoStream);

Future startVideoFuture = call.startVideo(appContext, currentLocalVideoStream);
startVideoFuture.get();

成功开始发送视频后,LocalVideoStream 实例将被添加到呼叫实例上的 localVideoStreams 集合中。

List<LocalVideoStream> videoStreams = call.getLocalVideoStreams();
LocalVideoStream currentLocalVideoStream = videoStreams.get(0); // Please make sure there are VideoStreams in the list before calling get(0).

若要停止本地视频,请传递 localVideoStreams 集合中可用的 LocalVideoStream 实例:

call.stopVideo(appContext, currentLocalVideoStream).get();

LocalVideoStream 实例调用 switchSource 来发送视频时,可切换到不同的相机设备:

currentLocalVideoStream.switchSource(source).get();

呈现远程参与者视频流

若要列出远程参与者的视频流和屏幕共享流,请检查 videoStreams 集合:

List<RemoteParticipant> remoteParticipants = call.getRemoteParticipants();
RemoteParticipant remoteParticipant = remoteParticipants.get(0); // Please make sure there are remote participants in the list before calling get(0).

List<RemoteVideoStream> remoteStreams = remoteParticipant.getVideoStreams();
RemoteVideoStream remoteParticipantStream = remoteStreams.get(0); // Please make sure there are video streams in the list before calling get(0).

MediaStreamType streamType = remoteParticipantStream.getType(); // of type MediaStreamType.Video or MediaStreamType.ScreenSharing

若要呈现来自远程参与者的 RemoteVideoStream,必须订阅 OnVideoStreamsUpdated 事件。

在此事件中,将 isAvailable 属性更改为 true 表示远程参与者当前正在发送流。 发生此情况后,请创建 Renderer 的新实例,然后使用异步 createView API 创建新的 RendererView,并在应用程序 UI 中的任意位置附加 view.target

当远程流的可用性发生变化时,可选择销毁整个呈现器、销毁特定的 RendererView,也可保留它们,但这将导致显示空白的视频帧。

VideoStreamRenderer remoteVideoRenderer = new VideoStreamRenderer(remoteParticipantStream, appContext);
VideoStreamRendererView uiView = remoteVideoRenderer.createView(new RenderingOptions(ScalingMode.FIT));
layout.addView(uiView);

remoteParticipant.addOnVideoStreamsUpdatedListener(e -> onRemoteParticipantVideoStreamsUpdated(p, e));

void onRemoteParticipantVideoStreamsUpdated(RemoteParticipant participant, RemoteVideoStreamsEvent args) {
    for(RemoteVideoStream stream : args.getAddedRemoteVideoStreams()) {
        if(stream.getIsAvailable()) {
            startRenderingVideo();
        } else {
            renderer.dispose();
        }
    }
}

远程视频流属性

远程视频流具有几个属性

  • Id - 远程视频流的 ID
int id = remoteVideoStream.getId();
  • MediaStreamType - 可以是“Video”或“ScreenSharing”
MediaStreamType type = remoteVideoStream.getMediaStreamType();
  • isAvailable - 指示远程参与者终结点是否正在主动发送流
boolean availability = remoteVideoStream.isAvailable();

呈现器方法和属性

采用 API 的呈现器对象

  • 创建一个 VideoStreamRendererView 实例,随后可将其附加到应用程序 UI 中来呈现远程视频流。
// Create a view for a video stream
VideoStreamRendererView.createView()
  • 处置呈现器及其所有相关 VideoStreamRendererView。 从 UI 中删除所有关联视图后,系统会调用它。
VideoStreamRenderer.dispose()
  • StreamSize - 远程视频流的大小(宽度/高度)
StreamSize renderStreamSize = VideoStreamRenderer.getSize();
int width = renderStreamSize.getWidth();
int height = renderStreamSize.getHeight();

RendererView 方法和属性

创建 VideoStreamRendererView 时,可指定将应用于此视图的 ScalingModemirrored 属性:缩放模式可以是“裁剪”或“拟合”

VideoStreamRenderer remoteVideoRenderer = new VideoStreamRenderer(remoteVideoStream, appContext);
VideoStreamRendererView rendererView = remoteVideoRenderer.createView(new CreateViewOptions(ScalingMode.Fit));

然后,可使用以下代码片段将创建的 RendererView 附加到应用程序 UI:

layout.addView(rendererView);

稍后可在 RendererView 对象上调用 updateScalingMode API,并将 ScalingMode.CROP 或 ScalingMode.FIT 作为参数来更新缩放模式。

// Update the scale mode for this view.
rendererView.updateScalingMode(ScalingMode.CROP)

设置系统

按照以下步骤设置系统。

创建 Xcode 项目

在 Xcode 中,创建新的 iOS 项目,并选择“单视图应用”模板。 本文使用 SwiftUI 框架,因此应将“语言”设置为“Swift”,并将“界面”设置为“SwiftUI”

在本文中,无需创建测试。 请随意清除“包括测试”复选框

显示用于在 Xcode 中创建项目的窗口的屏幕截图。

使用 CocoaPods 安装包和依赖项

  1. 为应用程序创建 Podfile,如此示例所示:

    platform :ios, '13.0'
    use_frameworks!
    target 'AzureCommunicationCallingSample' do
        pod 'AzureCommunicationCalling', '~> 1.0.0'
    end
    
  2. 运行 pod install

  3. 使用 Xcode 打开 .xcworkspace

请求访问麦克风

若要访问设备的麦克风,需要使用 NSMicrophoneUsageDescription 更新应用的信息属性列表。 将关联的值设置为一个字符串,该字符串将包含在系统用于向用户请求访问权限的对话框中。

右键单击项目树的 Info.plist 条目,然后选择“打开为...”>“源代码”。 将以下代码行添加到顶层 <dict> 节,然后保存文件。

<key>NSMicrophoneUsageDescription</key>
<string>Need microphone access for VOIP calling.</string>

设置应用框架

打开项目的 ContentView.swift 文件。 将 import 声明添加到文件顶部以导入 AzureCommunicationCalling 库。 此外,导入 AVFoundation。 你需要用它来处理代码中的音频权限请求。

import AzureCommunicationCalling
import AVFoundation

初始化 CallAgent

若要从 CallClient 创建 CallAgent 实例,必须使用 callClient.createCallAgent 方法,该方法在初始化后异步返回 CallAgent 对象。

若要创建通话客户端,请传递 CommunicationTokenCredential 对象:

import AzureCommunication

let tokenString = "token_string"
var userCredential: CommunicationTokenCredential?
do {
    let options = CommunicationTokenRefreshOptions(initialToken: token, refreshProactively: true, tokenRefresher: self.fetchTokenSync)
    userCredential = try CommunicationTokenCredential(withOptions: options)
} catch {
    updates("Couldn't created Credential object", false)
    initializationDispatchGroup!.leave()
    return
}

// tokenProvider needs to be implemented by Contoso, which fetches a new token
public func fetchTokenSync(then onCompletion: TokenRefreshOnCompletion) {
    let newToken = self.tokenProvider!.fetchNewToken()
    onCompletion(newToken, nil)
}

将创建的 CommunicationTokenCredential 对象传递给 CallClient 并设置显示名称:

self.callClient = CallClient()
let callAgentOptions = CallAgentOptions()
options.displayName = " iOS Azure Communication Services User"

self.callClient!.createCallAgent(userCredential: userCredential!,
    options: callAgentOptions) { (callAgent, error) in
        if error == nil {
            print("Create agent succeeded")
            self.callAgent = callAgent
        } else {
            print("Create agent failed")
        }
})

管理设备

若要开始将视频与通话一起使用,需要知道如何管理设备。 使用设备可以控制将音频和视频传输到通话。

借助 DeviceManager,可枚举通话中可用于传输音频或视频流的本地设备。 你还可用它来向用户请求访问麦克风或相机的权限。 可访问 callClient 对象上的 deviceManager

self.callClient!.getDeviceManager { (deviceManager, error) in
        if (error == nil) {
            print("Got device manager instance")
            self.deviceManager = deviceManager
        } else {
            print("Failed to get device manager instance")
        }
    }

枚举本地设备

若要访问本地设备,可在设备管理器上使用枚举方法。 枚举是同步操作。

// enumerate local cameras
var localCameras = deviceManager.cameras // [VideoDeviceInfo, VideoDeviceInfo...]

获取本地相机预览

可使用 Renderer 开始呈现来自本地相机的流。 此流不会发送给其他参与者;这是一项本地预览源。 这是异步操作。

let camera: VideoDeviceInfo = self.deviceManager!.cameras.first!
let localVideoStream = LocalVideoStream(camera: camera)
let localRenderer = try! VideoStreamRenderer(localVideoStream: localVideoStream)
self.view = try! localRenderer.createView()

获取本地相机预览属性

呈现器包含一组属性和方法,可使用它们来控制呈现。

// Constructor can take in LocalVideoStream or RemoteVideoStream
let localRenderer = VideoStreamRenderer(localVideoStream:localVideoStream)
let remoteRenderer = VideoStreamRenderer(remoteVideoStream:remoteVideoStream)

// [StreamSize] size of the rendering view
localRenderer.size

// [VideoStreamRendererDelegate] an object you provide to receive events from this Renderer instance
localRenderer.delegate

// [Synchronous] create view
try! localRenderer.createView()

// [Synchronous] create view with rendering options
try! localRenderer!.createView(withOptions: CreateViewOptions(scalingMode: ScalingMode.fit))

// [Synchronous] dispose rendering view
localRenderer.dispose()

发起一对一视频通话

若要获取设备管理器实例,请查看有关管理设备的部分。

let firstCamera = self.deviceManager!.cameras.first
self.localVideoStreams = [LocalVideoStream]()
self.localVideoStreams!.append(LocalVideoStream(camera: firstCamera!))
let videoOptions = VideoOptions(localVideoStreams: self.localVideoStreams!)

let startCallOptions = StartCallOptions()
startCallOptions.videoOptions = videoOptions

let callee = CommunicationUserIdentifier('UserId')
self.callAgent?.startCall(participants: [callee], options: startCallOptions) { (call, error) in
    if error == nil {
        print("Successfully started outgoing video call")
        self.call = call
    } else {
        print("Failed to start outgoing video call")
    }
}

呈现远程参与者视频流

远程参与者可在通话期间发起视频或屏幕共享。

处理远程参与者的视频共享或屏幕共享流

若要列出远程参与者的流,请检查 videoStreams 集合。

var remoteParticipantVideoStream = call.remoteParticipants[0].videoStreams[0]

获取远程视频流属性

var type: MediaStreamType = remoteParticipantVideoStream.type // 'MediaStreamTypeVideo'
var isAvailable: Bool = remoteParticipantVideoStream.isAvailable // indicates if remote stream is available
var id: Int = remoteParticipantVideoStream.id // id of remoteParticipantStream

呈现远程参与者流

若要开始呈现远程参与者流,请使用以下代码。

let renderer = VideoStreamRenderer(remoteVideoStream: remoteParticipantVideoStream)
let targetRemoteParticipantView = renderer?.createView(withOptions: CreateViewOptions(scalingMode: ScalingMode.crop))
// To update the scaling mode later
targetRemoteParticipantView.update(scalingMode: ScalingMode.fit)

获取远程视频呈现器方法和属性

// [Synchronous] dispose() - dispose renderer and all `RendererView` associated with this renderer. To be called when you have removed all associated views from the UI.
remoteVideoRenderer.dispose()

设置系统

按照以下步骤设置系统。

创建 Visual Studio 项目

对于通用 Windows 平台应用,请在 Visual Studio 2022 中创建新的“空白应用(通用 Windows)”项目。 输入项目名称后,可随意选择任何版本高于 10.0.17763.0 的 Windows SDK。

对于 WinUI 3 应用,请使用“已打包空白应用(桌面中的 WinUI 3)”模板创建新项目,以设置单页 WinUI 3 应用。 需要 Windows App SDK 版本 1.3 或更高版本。

使用 NuGet 包管理器安装包和依赖项

可通过 NuGet 包公开提供通话 SDK API 和库。

要查找、下载和安装通话 SDK NuGet 包,请执行以下操作:

  1. 选择“工具”>“NuGet 包管理器”>“管理解决方案的 NuGet 包”,以打开 NuGet 包管理器
  2. 选择“浏览”,然后在搜索框中输入 Azure.Communication.Calling.WindowsClient
  3. 确保已选中“包括预发行版”复选框
  4. 选择 Azure.Communication.Calling.WindowsClient 包,然后选择 Azure.Communication.Calling.WindowsClient 1.4.0-beta.1 或更新版本。
  5. 在右侧窗格中选中与 Azure 通信服务项目对应的复选框。
  6. 选择“安装” 。

请求访问麦克风

应用需要访问相机才能正常运行。 在 UWP 应用中,应在应用清单文件中声明摄像头功能。

以下步骤举例说明了如何实现此操作。

  1. Solution Explorer 面板中,双击扩展名为 .appxmanifest 的文件。
  2. 单击 Capabilities 选项卡。
  3. 从功能列表中选中 Camera 复选框。

创建用于发起和挂起通话的 UI 按钮

这个简单的示例应用包含两个按钮。 一个用于发起通话,另一个用于挂起已发起的通话。 以下步骤将举例说明如何将这些按钮添加到应用。

  1. Solution Explorer 面板中,对于 UWP,双击名为 MainPage.xaml 的文件;对于 WinUI 3,双击名为 MainWindows.xaml 的文件。
  2. 在中央面板中的 UI 预览下查找 XAML 代码。
  3. 修改 XAML 代码,摘录如下:
<TextBox x:Name="CalleeTextBox" PlaceholderText="Who would you like to call?" />
<StackPanel>
    <Button x:Name="CallButton" Content="Start/Join call" Click="CallButton_Click" />
    <Button x:Name="HangupButton" Content="Hang up" Click="HangupButton_Click" />
</StackPanel>

使用通话 SDK API 设置应用

通话 SDK API 在两个不同的命名空间中。 以下步骤告知 C# 编译器关于这些命名空间的信息,以便 Visual Studio Intellisense 帮助进行代码开发。

  1. Solution Explorer 面板中,对于 UWP,单击名为 MainPage.xaml 的文件左侧的箭头;对于 WinUI 3,单击名为 MainWindows.xaml 的文件左侧的箭头。
  2. 双击名为 MainPage.xaml.csMainWindows.xaml.cs 的文件。
  3. 在当前 using 语句的底部添加以下命令。
using Azure.Communication.Calling.WindowsClient;

保持 MainPage.xaml.csMainWindows.xaml.cs 打开。 接下来的步骤将添加更多代码。

允许应用交互

先前添加的 UI 按钮需基于发起的 CommunicationCall 才能正常工作。 这意味着,CommunicationCall 数据成员应被添加到 MainPageMainWindow 类。 此外,若要使创建 CallAgent 的异步操作成功,还应将 CallAgent 数据成员添加到同一类。

将以下数据成员添加到 MainPageMainWindow 类:

CallAgent callAgent;
CommunicationCall call;

创建按钮处理程序

之前向 XAML 代码添加了两个 UI 按钮。 以下代码添加了在用户选择按钮时要执行的处理程序。 应在上一部分的数据成员之后添加以下代码。

private async void CallButton_Click(object sender, RoutedEventArgs e)
{
    // Start call
}

private async void HangupButton_Click(object sender, RoutedEventArgs e)
{
    // End the current call
}

对象模型

以下类和接口处理适用于 UWP 的 Azure 通信服务通话客户端库的某些主要功能。

名称 说明
CallClient CallClient 是通话客户端库的主入口点。
CallAgent CallAgent 用于启动和加入通话。
CommunicationCall CommunicationCall 用于管理已发起或已加入的通话。
CommunicationTokenCredential CommunicationTokenCredential 用作实例化 CallAgent 的令牌凭据。
CallAgentOptions CallAgentOptions 包含用于标识呼叫方的信息。
HangupOptions HangupOptions 告知是否应终止其所有参与者的通话。

注册视频架构处理程序

UI 组件(如 XAML 的 MediaElement 或 MediaPlayerElement),需要应用注册配置以呈现本地和远程视频源。 在 Package.appxmanifestPackage 标记之间添加以下内容:

<Extensions>
    <Extension Category="windows.activatableClass.inProcessServer">
        <InProcessServer>
            <Path>RtmMvrUap.dll</Path>
            <ActivatableClass ActivatableClassId="VideoN.VideoSchemeHandler" ThreadingModel="both" />
        </InProcessServer>
    </Extension>
</Extensions>

初始化 CallAgent

若要从 CallClient 创建 CallAgent 实例,必须使用 CallClient.CreateCallAgentAsync 方法,该方法在初始化后异步返回 CallAgent 对象。

若要创建 CallAgent,必须传递 CallTokenCredential 对象和 CallAgentOptions 对象。 请记住,如果传递了格式错误的令牌,则会引发 CallTokenCredential

应在要在应用初始化过程中调用的帮助程序函数中添加以下代码。

var callClient = new CallClient();
this.deviceManager = await callClient.GetDeviceManagerAsync();

var tokenCredential = new CallTokenCredential("<AUTHENTICATION_TOKEN>");
var callAgentOptions = new CallAgentOptions()
{
    DisplayName = "<DISPLAY_NAME>"
};

this.callAgent = await callClient.CreateCallAgentAsync(tokenCredential, callAgentOptions);
this.callAgent.CallsUpdated += Agent_OnCallsUpdatedAsync;
this.callAgent.IncomingCallReceived += Agent_OnIncomingCallAsync;

为你的资源将 <AUTHENTICATION_TOKEN> 更改为有效凭据令牌。 如果必须获取凭据令牌,请参阅用户访问令牌文档。

发起启用相机的一对一通话

创建 CallAgent 所需的对象现已准备就绪。 现在,可以异步创建 CallAgent 并发起视频通话。

private async void CallButton_Click(object sender, RoutedEventArgs e)
{
    var callString = CalleeTextBox.Text.Trim();

    if (!string.IsNullOrEmpty(callString))
    {
        if (callString.StartsWith("8:")) // 1:1 Azure Communication Services call
        {
            this.call = await StartAcsCallAsync(callString);
        }
    }

    if (this.call != null)
    {
        this.call.RemoteParticipantsUpdated += OnRemoteParticipantsUpdatedAsync;
        this.call.StateChanged += OnStateChangedAsync;
    }
}

private async Task<CommunicationCall> StartAcsCallAsync(string acsCallee)
{
    var options = await GetStartCallOptionsAsynnc();
    var call = await this.callAgent.StartCallAsync( new [] { new UserCallIdentifier(acsCallee) }, options);
    return call;
}

var micStream = new LocalOutgoingAudioStream(); // Create a default local audio stream
var cameraStream = new LocalOutgoingVideoStreamde(this.viceManager.Cameras.FirstOrDefault() as VideoDeviceDetails); // Create a default video stream

private async Task<StartCallOptions> GetStartCallOptionsAsynnc()
{
    return new StartCallOptions() {
        OutgoingAudioOptions = new OutgoingAudioOptions() { IsMuted = true, Stream = micStream  },
        OutgoingVideoOptions = new OutgoingVideoOptions() { Streams = new OutgoingVideoStream[] { cameraStream } }
    };
}

本地相机预览

我们可以选择设置本地相机预览。 视频可以通过 MediaPlayerElement 呈现:

<Grid>
    <MediaPlayerElement x:Name="LocalVideo" AutoPlay="True" />
    <MediaPlayerElement x:Name="RemoteVideo" AutoPlay="True" />
</Grid>

要初始化本地预览 MediaPlayerElement,请执行以下操作:

private async void CameraList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
    if (cameraStream != null)
    {
        await cameraStream?.StopPreviewAsync();
        if (this.call != null)
        {
            await this.call?.StopVideoAsync(cameraStream);
        }
    }
    var selectedCamerea = CameraList.SelectedItem as VideoDeviceDetails;
    cameraStream = new LocalOutgoingVideoStream(selectedCamerea);

    var localUri = await cameraStream.StartPreviewAsync();
    LocalVideo.Source = MediaSource.CreateFromUri(localUri);

    if (this.call != null) {
        await this.call?.StartVideoAsync(cameraStream);
    }
}

呈现远程相机流式传输

若要设置事件处理程序以响应 OnCallsUpdated 事件,请执行以下操作:

private async void OnCallsUpdatedAsync(object sender, CallsUpdatedEventArgs args)
{
    var removedParticipants = new List<RemoteParticipant>();
    var addedParticipants = new List<RemoteParticipant>();

    foreach(var call in args.RemovedCalls)
    {
        removedParticipants.AddRange(call.RemoteParticipants.ToList<RemoteParticipant>());
    }

    foreach (var call in args.AddedCalls)
    {
        addedParticipants.AddRange(call.RemoteParticipants.ToList<RemoteParticipant>());
    }

    await OnParticipantChangedAsync(removedParticipants, addedParticipants);
}

private async void OnRemoteParticipantsUpdatedAsync(object sender, ParticipantsUpdatedEventArgs args)
{
    await OnParticipantChangedAsync(
        args.RemovedParticipants.ToList<RemoteParticipant>(),
        args.AddedParticipants.ToList<RemoteParticipant>());
}

private async Task OnParticipantChangedAsync(IEnumerable<RemoteParticipant> removedParticipants, IEnumerable<RemoteParticipant> addedParticipants)
{
    foreach (var participant in removedParticipants)
    {
        foreach(var incomingVideoStream in  participant.IncomingVideoStreams)
        {
            var remoteVideoStream = incomingVideoStream as RemoteIncomingVideoStream;
            if (remoteVideoStream != null)
            {
                await remoteVideoStream.StopPreviewAsync();
            }
        }
        participant.VideoStreamStateChanged -= OnVideoStreamStateChanged;
    }

    foreach (var participant in addedParticipants)
    {
        participant.VideoStreamStateChanged += OnVideoStreamStateChanged;
    }
}

private void OnVideoStreamStateChanged(object sender, VideoStreamStateChangedEventArgs e)
{
    CallVideoStream callVideoStream = e.CallVideoStream;

    switch (callVideoStream.StreamDirection)
    {
        case StreamDirection.Outgoing:
            OnOutgoingVideoStreamStateChanged(callVideoStream as OutgoingVideoStream);
            break;
        case StreamDirection.Incoming:
            OnIncomingVideoStreamStateChanged(callVideoStream as IncomingVideoStream);
            break;
    }
}

开始在 MediaPlayerElement 上呈现远程视频流:

private async void OnIncomingVideoStreamStateChanged(IncomingVideoStream incomingVideoStream)
{
    switch (incomingVideoStream.State)
    {
        case VideoStreamState.Available:
            {
                switch (incomingVideoStream.Kind)
                {
                    case VideoStreamKind.RemoteIncoming:
                        var remoteVideoStream = incomingVideoStream as RemoteIncomingVideoStream;
                        var uri = await remoteVideoStream.StartPreviewAsync();

                        await Dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.Normal, () =>
                        {
                            RemoteVideo.Source = MediaSource.CreateFromUri(uri);
                        });

                        /* Or WinUI 3
                        this.DispatcherQueue.TryEnqueue(() => {
                            RemoteVideo.Source = MediaSource.CreateFromUri(uri);
                            RemoteVideo.MediaPlayer.Play();
                        });
                        */

                        break;

                    case VideoStreamKind.RawIncoming:
                        break;
                }

                break;
            }
        case VideoStreamState.Started:
            break;
        case VideoStreamState.Stopping:
            break;
        case VideoStreamState.Stopped:
            if (incomingVideoStream.Kind == VideoStreamKind.RemoteIncoming)
            {
                var remoteVideoStream = incomingVideoStream as RemoteIncomingVideoStream;
                await remoteVideoStream.StopPreviewAsync();
            }
            break;
        case VideoStreamState.NotAvailable:
            break;
    }
}

结束呼叫

发起通话后,应使用 CommunicationCall 对象的 HangupAsync 方法挂起通话。

还应使用 HangupOptions 的实例来通知是否必须终止所有参与者的通话。

应在 HangupButton_Click 中添加以下代码。

var call = this.callAgent?.Calls?.FirstOrDefault();
if (call != null)
{
    var call = this.callAgent?.Calls?.FirstOrDefault();
    if (call != null)
    {
        foreach (var localVideoStream in call.OutgoingVideoStreams)
        {
            await call.StopVideoAsync(localVideoStream);
        }

        try
        {
            if (cameraStream != null)
            {
                await cameraStream.StopPreviewAsync();
            }

            await call.HangUpAsync(new HangUpOptions() { ForEveryone = false });
        }
        catch(Exception ex) 
        { 
            var errorCode = unchecked((int)(0x0000FFFFU & ex.HResult));
            if (errorCode != 98) // Sample error code, sam_status_failed_to_hangup_for_everyone (98)
            {
                throw;
            }
        }
    }
}

运行代码

请确保 Visual Studio 生成适用于 x64x86ARM64 的应用,然后按 F5 开始运行应用。 然后,单击 CommunicationCall 按钮,向定义的被呼叫者发起通话。

请注意,应用首次运行时,系统会提示用户授予对麦克风的访问权限。

后续步骤