环信uni-app-demo 升级改造计划——单人多人音视频通话(三)

前序文章:

环信 uni-app Demo升级改造计划——Vue2迁移到Vue3(一)

环信即时通讯SDK集成——环信 uni-app-demo 升级改造计划——整体代码重构优化(二)

概述

在将声网 uni-app 音视频插件正式集成进入环信的 uni-app-demo 中,标志着本次升级改造至此基本告一段落。在第三期的升级改造中,主要工作为在 Demo 层形成一个较为容易拆分的有关音视频相关组件,力求第一:代码是否可读、第二:可以对参考源码的同学提供实例、第三:能够方便在脱离其他 IM 功能时,完成对音视频功能的复用。

同时也顺手针对 emChat 组件进行小范围重构,解决了 uni-app 在 App 以及小程序端,软键盘弹起消息列表不滚动以及软键盘遮挡功能栏问题。

下面我将尽可能详细描述一下本次针对音视频功能、以及消息列表重写的心路历程。

功能背景以及目的

有越来越多的用户在 IM 功能实现中不免向类似微信聊天的功能靠齐,除了日常 IM 功能中,也离不开音视频通话功能,因此需要在环信uni-app-demo中增加实现音视频通话的示例代码,能够对想要实现音视频功能的用户形成可参考的 demo 代码,以及可复用的音视频功能模块组件。

前置准备

  • 确认实现功能范围

    接听呼叫(单聊一对一、群组多人音视频通话)且只支持 uni-app 原生端使用。

  • 浏览声网音视频 uni-app 端相关文档,熟悉大致流程以及熟悉部分核心 API,跑通示例 Demo。
  • 熟悉环信其他端PCWeb端、安卓、iOS端callKit 信令交互相关逻辑,确保实现 uni-app 所实现的音视频功能能够与其他端 Demo 进行互通。
  • 了解nvue组件相关语法布局样式等与vue的差异,推拉流视频容器仅支持在nvue组件中进行使用。

实践见真章

Tip:以下展示代码因篇幅所限,均做了不同程度的删减保留了核心逻辑展示,详细代码文末会给出源码地址。

step1:在项目中集成音视频相关插件

Agora(声网)Demo 示例中有两个插件是必须要进行集成的,分别为Native原生插件,Js插件

Agora-Demo 示例插件下载地址以及功能简介详见下方提供的链接。

  • Quick-start-demo
  • Agora 原生插件地址
  • Agora JS 插件地址

具体插件的导入方式就不在本篇中详细介绍,上方插件下载地址中有提到插件导入方式,可以进行参阅。

特别注意:Agora-Uni-App JS 插件导入之后会在目录下生成一个package.json文件,这个文件会与通过 npm 导入的easemob-websdkpackage.json重合,因此 Demo 中只保留了easemob-demopackage.json

step2: 设计搭建 emCallKit(音视频组件)逻辑结构

主体大致结构如下:

CallKit
emCallkit
callKitManage
config
contants
stores
utils
index.js
emCallkitPages
alertScreen.vue
inviteMembers.vue
multiCall.nvue
singleCall.nvue

其中components/emCallKit主要为核心 emCallKit 逻辑层代码,callKitManage文件中主要包含对外发布订阅频道内时间逻辑代码,以及频道内信令发送代码。config声网 AppId 配置。contants文件夹音视频频道内常量、stores频道内核心逻辑在此,利用 pinia 进行频道内状态管理。utils工具方法,index.jsemCallkit 入口文件,该文件内挂载信令监听初始化频道内 IM Client。

pages/emCallKitPages则是频道内各个页面在此构造,alertScreen.vue单人多人收到邀请弹出该页面,单人呼叫也使用该页面。inviteMembers.vue多人邀请页面。multiCall.nvue多人通话中页面。singleCall.nvue单人通话中页面。

step3:实现单人音视频信令接收以及发送

在思考实现单人音视频拨打之前需要了解其他端已经实现的音视频时序,
以单人音视频呼叫为例:

Alice 为呼叫方 John 为接收方

Alice John invite message(邀请您进行单人音视频通话) alerting confirmRing answerCall confirmCallee Alice John

可以看到与 http 的”握手“过程相似,需要经过几次确认,这样频繁的确认意义在于,能否保证通话状态的准确性,且有效防止在离线的情况下,上线无故触发已经失效的邀请弹窗。而上面的除了邀请的消息为一条普通文本消息,整个过程都是通过环信 IM 的CMD命令消息实现,且每条消息信令中都有携带一些声网频道信息,比如频道名称,呼叫的类型等都是基于CMD命令消息实现。

为了能够独立于 IM 功能之外去使用音视频插件,因此在书写时尽可能的与外层 IM Demo 中的逻辑分离开,比如 callKit 中有用到消息监听用来监听消息以及发送 im 消息,因此将实例化后的 websdk(暂称:EMClient)传入到 emCallKit 中,并利用 websdk 支持多处挂载监听回调的特性,通过拿到传入EMClient.send进行消息发送,并使用EMClient.addEventHandler进行监听的挂载,便形成了如下缩减后的代码:

/* 频道信令发送 */
import useSendSignalMsgs from './callKitManage/useSendSignalMsgs';
let CallKitEMClient = null;
let CallKitCreateMsgFun = null;
export const useInitCallKit = () => {//初始化EMClient之Callkit内const setCallKitClient = (EMClient, CreateMsgFun) => {CallKitEMClient = EMClient;CallKitCreateMsgFun = CreateMsgFun;mountSignallingListener();};//挂载Callkit信令相关监听const mountSignallingListener = () => {console.log('>>>>>>>callkit 监听已挂载');CallKitEMClient.addEventHandler('callkitSignal', {onTextMessage: (message) => {const { ext } = message;if (ext && ext?.action === CALL_ACTIONS_TYPE.INVITE)handleCallKitInvite(message);console.log('>>>>>收到文本信令消息', message);},onCmdMessage: (msg) => {console.log('>>>>>收到命令信令消息', msg);if (msg && msg?.action === CALL_ACTIONS_TYPE.RTC_CALL)handleCallKitCommand(msg);},});//处理收到为文本的邀请信息const handleCallKitInvite = (msgBody) => {console.log('>>>>>开始处理被邀请消息');const { from, ext } = msgBody || {};//邀请消息发送者为自己则忽略if (from === CallKitEMClient.user) return;};//处理接收到通话交互过程的CMD命令消息const handleCallKitCommand = (msgBody) => {//多端状态下信令消息发送者为自己则忽略if (msgBody.from === CallKitEMClient.user) return;};};};return {CallKitEMClient,CallKitCreateMsgFun,setCallKitClient,};
};
//外层调用初始化callKit频道
import { EMClient, EaseSDK } from './EaseIM';
/* callKit */
import { useInitCallKit } from '@/components/emCallKit';
const { setCallKitClient } = useInitCallKit();
setCallKitClient(EMClient, EaseSDK.message);

至此就可以做到了,在初始化的时候完成针对 callKit 监听的挂载,能够做到在 callKit 中单独接收 im 相关邀请消息以及信令。
下面解决 im 信令发的问题
如上面描述的 callKit 项目结构一致,在callKitManage文件夹下新建useSendSignalMsgs.js文件主要处理有关信令发送核心代码,从而解决信令的发送问题。

/* 用来发送所有频道内信令使用 */
import { CALL_ACTIONS_TYPE, MSG_TYPE } from '../contants';
import { useInitCallKit } from '../index.js';const action = 'rtcCall';
const useSendSignalMsgs = () => {const { CallKitEMClient, CallKitCreateMsgFun } = useInitCallKit();//发送通知弹出待接听窗口信令const sendAlertMsg = (payload) => {const { from, ext } = payload;const option = {type: 'cmd',chatType: 'singleChat',to: from,action: action,ext: {action: CALL_ACTIONS_TYPE.ALERT,calleeDevId: CallKitEMClient.context.jid.clientResource,callerDevId: ext.callerDevId,callId: ext.callId,ts: Date.now(),msgType: MSG_TYPE,},};console.log('>>>>>>>option', option);const msg = CallKitCreateMsgFun.create(option);// 调用 `send` 方法发送该透传消息。CallKitEMClient.send(msg).then((res) => {// 消息成功发送回调。console.log('answer Success', res);}).catch((e) => {// 消息发送失败回调。console.log('anser Fail', e);});};return {sendAlertMsg,};
};
export default useSendSignalMsgs;
//发送时调用
import useSendSignalMsgs from '../callKitManage/useSendSignalMsgs';
const { sendAnswerMsg } = useSendSignalMsgs();
const payload = {targetId: from,sendBody: ext,
};
sendAnswerMsg(payload, ANSWER_TYPE.BUSY);

到这里,关于 callKit 组件内的有关信令部分的核心代码的设计就此结束。

step4:搭建频道内管理相关代码

频道管理是必须要做的,试想一个小场景,张三正在与李四进行音视频通话,此时王五呼叫过来,如果不做什么状态的管理,收到王五的视频邀请就立马弹出了一个邀请弹窗,但是此时张三却已经在通话中了,那么从代码的角度讲这个已经算是一个较为严重的 Bug 了,因此我们必须要在频道中引入状态管理这个概念,这个概念的实现即不是环信IM层面,也不是声网RTC,而是我们自己需要实现的一个状态,比如空闲、呼叫中、邀请中、通话中等等,我们需要抽象出来一个频道状态从而映射出用户在使用音视频通话功能中不同时期的情况,并且做出不同的逻辑层处理。

在引入状态管理的情况下,再去套用刚才的场景:
张三在收到李四的通话邀请时,张三本身为空闲状态,此时就可以回复给李四状态空闲可以通话,李四收到张三的回复后可以调起通话待接听界面,直到张三接听后双方可进入到频道中,正常进行通话功能的使用,此时王五呼叫张三,引领发出后,张三收到邀请信令,获取当前状态为通话中,则直接根据获取的状态判断直接回复BUSY忙碌中,从而拒绝了王五的通话邀请。

可以看到引入了频道中的状态管理概念我们解决了音视频通话时避免状态混乱导致的一系列问题,下面可以看下示例代码。

import { defineStore } from 'pinia';
import useSendSignalMsgs from '../callKitManage/useSendSignalMsgs';
import createUid from '../utils/createUid';
const useAgoraChannelStore = defineStore('agoraChannelStore', {state: () => ({emClientInfos: {apiUrl: '',appKey: '',loginUserId: '',clientResource: '',accessToken: '',},callKitStatus: {localClientStatus: CALLSTATUS.idle, //callkit状态channelInfos: {channelName: '', //频道名agoraChannelToken: '', //频道tokenagoraUserId: '', //频道用户id,callType: CALL_TYPES.SINGLE_VOICE, //0 语音 1 视频 2 多人音视频callId: null, //会议IDchannelUsers: {}, //频道内用户callerDevId: '', //主叫方设备IDcalleeDevId: '', //被叫方设备IDcallerIMName: '', //主叫方环信IDcalleeIMName: '', //被叫方环信IDgroupId: '', //群组ID},//被邀请对象 单人为string 多人为arrayinviteTarget: null,},}),actions: {/* emClient */initEmClientInfos(emClient) {console.log('initEmClientInfos', emClient);if (!emClient) return;this.emClientInfos.apiUrl = emClient.apiUrl;this.emClientInfos.appKey = emClient.appKey;this.emClientInfos.loginUserId = emClient.user;this.emClientInfos.accessToken = emClient.token;this.emClientInfos.clientResource = emClient.clientResource;},/* CallKit status 管理 *///初始化频道信息initChannelInfos() {this.callKitStatus.localClientStatus = CALLSTATUS.idle;this.callKitStatus.channelInfos = {channelName: '', //频道名agoraChannelToken: '', //频道tokenagoraUid: '', //频道用户idcallType: CALL_TYPES.SINGLE_VOICE, //0 语音 1 视频 2 多人音视频callId: null, //会议IDchannelUsers: {}, //频道内用户callerDevId: '', //主叫方设备IDcalleeDevId: '', //被叫方设备IDconfrontId: '', //要处理的目标IDcallerIMName: '', //主叫方环信IDcalleeIMName: '', //被叫方环信IDgroupId: '', //群组ID};this.callKitStatus.inviteTarget = null;this.callKitTimer && clearTimeout(this.callKitTimer);},//更新localStatusupdateLocalStatus(typeCode) {console.log('>>>>>开始变更本地状态为 typeCode', typeCode);this.callKitStatus.localClientStatus = typeCode;},//更新频道信息updateChannelInfos(msgBody) {console.log('触发更新频道信息', msgBody);const { from, to, ext } = msgBody || {};const params = {channelName:ext.channelName || this.callKitStatus.channelInfos.channelName,callId: ext.callId || this.callKitStatus.channelInfos.callId,callType:CALL_TYPE[ext.type] || this.callKitStatus.channelInfos.callType,callerDevId: ext.callerDevId || 0,calleeDevId: ext.calleeDevId,callerIMName: from,calleeIMName: to,groupId: ext?.ext?.groupId ? ext.ext.groupId : '',};console.log('%c将要更新的信息内容为', 'color:red', params);Object.assign(this.callKitStatus.channelInfos, params);},},
});
export default useAgoraChannelStore;
//频道状态使用以及变更示例代码
import useAgoraChannelStore from './stores/channelManger';
const { updateChannelInfos, updateLocalStatus } = agoraChannelStore;
const callKitStatus = computed(() => agoraChannelStore.callKitStatus);

上面示例代码,是针对频道内的状态管理演示代码,用到了 pinia 去进行状态存储以及管理,pinia 也支持在 nvue 页面中很方便的使用。

step5:关于 callKit 可视页面的处理

关于可视组件的处理是指的是,比如在收到邀请信息时需要弹出待接听页面,那么我们就需要跳转至待接听页面,多人通话时我们需要邀请更多人加入会议,那么我们则需要弹出邀请页面,单人以及多人通话中我们则需要跳转至实际需要显示通话双方音视频流的组件页面,上面提到的几个页面就分别对应了:alertScreen.vueinviteMembers.vuemultiCall.nvuesingleCall.nvue

这些组件由于是页面级别的,因此在需要跳转至对应的页面时,不免需要进行 router 路由映射关系配置,因此我们需要在pages.json中进行对应的页面地址配置,这里拿其中alertScreen.vue做代码演示。

pages.json 配置

{"path": "pages/emCallKitPages/alertScreen","style": {"app-plus": {"titleNView": false}}
}

跳转至待接听页面

import useCallKitEvent from '@/components/emCallKit/callKitManage/useCallKitEvent';
const { EVENT_NAME, CALLKIT_EVENT_CODE, SUB_CHANNEL_EVENT } = useCallKitEvent();
SUB_CHANNEL_EVENT(EVENT_NAME, (params) => {const { type, ext, callType, eventHxId } = params;console.log('>>>>>>订阅到callkit事件发布', params);//弹出待接听事件switch (type.code) {case CALLKIT_EVENT_CODE.ALERT_SCREEN:{//跳转至待接听页面uni.navigateTo({url: '../emCallKitPages/alertScreen',});}break;default:break;}
});

从待接听页面选择接听后的跳转

在待接听页面,点击接听后,应该是怎样的逻辑处理?

const agreeJoinChannel = () => {handleSendAnswerMsg(ANSWER_TYPE.ACCPET);if (channelInfos.value.callType === CALL_TYPES.MULTI_VIDEO) {uni.redirectTo({url: '/pages/emCallKitPages/multiCall',});} else {enterSingleCallPage();}
};
const enterSingleCallPage = () => {uni.redirectTo({url: '/pages/emCallKitPages/singleCall',});
};

可以看到上面的演示代码做了两种通话大类(单人、多人)不同的页面跳转。

下面我们看下通话中的视图页面是怎样的(singleCall为例),同样代码做了一部分的删减。

<template><div class="single_call_container"><!-- 视频视图 --><viewclass="rtc_view_container"v-if="callKitStatus.channelInfos.callType === CALL_TYPES.SINGLE_VIDEO"><view class="local_container"><rtc-surface-viewv-if="state.engine"class="local_view_stream":uid="0":zOrderMediaOverlay="true"></rtc-surface-view></view><view class="remote_container"><rtc-surface-viewclass="remote_view_stream":uid="state.remoteUid"></rtc-surface-view></view></view><!-- 语音视图 --><viewclass="rtc_voice_container"v-if="callKitStatus.channelInfos.callType === CALL_TYPES.SINGLE_VOICE"><view class="circleBodyView"><imageclass="circleItemAvatar"src="/static/emCallKit/theme2x.png"></image><view class="circleCenter"><text class="cenametext">{{ callKitStatus.inviteTarget ||callKitStatus.channelInfos.callerIMName }}</text><text class="centertext">正在语音通话…</text></view></view></view><!-- 页面控制 --><view class="rtc_control"><view class="circleBoxView"><text class="hint">{{ formatTime }}</text></view><view class="circleBoxView"><view class="circleBox" @click="onSwitchLocalMicPhone"><imageclass="circleImg":src="state.isMuteLocalAudioStream? '/static/emCallKit/icon_video_quiet.png': '/static/emCallKit/icon_video_microphone.png'"></image><text class="hint">麦克风</text></view><view class="circleBox" @click="onSwitchSperkerPhone"><imageclass="circleImg":src="state.isSwitchSperkerPhone? '/static/emCallKit/icon_video_speaker.png': '/static/emCallKit/icon_video_speakerno.png'"></image><text class="hint">扬声器</text></view><viewv-if="callKitStatus.channelInfos.callType === CALL_TYPES.SINGLE_VIDEO"class="circleBox"@click="onSwitchLocalCameraOpened"><imageclass="circleImg":src="state.isSwitchLocalCameraOpened? '/static/emCallKit/icon_video_speaker.png': '/static/emCallKit/icon_video_speakerno.png'"></image><text class="hint">摄像头</text></view></view><view class="circleBoxView"><view class="circleBox" @click="leaveChannel"><imageclass="circleImg"src="/static/emCallKit/icon_video_cancel.png"></image><text class="hint">挂断</text></view></view><imagev-if="callKitStatus.channelInfos.callType === CALL_TYPES.SINGLE_VIDEO"class="switchCamera"@click="onSwitchCamera"src="/static/emCallKit/iconxiangjifanzhuan.png"></image></view></div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue';
import { onLoad, onUnload } from '@dcloudio/uni-app';
import { AGORA_APP_ID } from '@/components/emCallKit/config/index.js';
import { CALLSTATUS, CALL_TYPES } from '@/components/emCallKit/contants';
import RtcEngine, { RtcChannel } from '@/components/Agora-RTC-JS/index';
import {ClientRole,ChannelProfile,
} from '@/components/Agora-RTC-JS/common/Enums';
import RtcSurfaceView from '@/components/Agora-RTC-JS/RtcSurfaceView';
import useAgoraChannelStore from '@/components/emCallKit/stores/channelManger';//获取移动端授权权限
import permision from '@/js_sdk/wa-permission/permission';
//store
const agoraChannelStore = useAgoraChannelStore();
//channelInfos
const callKitStatus = computed(() => {return agoraChannelStore.callKitStatus;
});
//channelName
const channelName = computed(() => agoraChannelStore.callKitStatus.channelInfos?.channelName
);
const state = reactive({engine: undefined,channelId: '',isJoined: false,remoteUid: '',isSwitchCamera: true,isSwitchSperkerPhone: true,isMuteLocalAudioStream: false,isSwitchLocalCameraOpened: true,
});
//开启通话计时
const inChannelTimer = ref(null);
const timeCount = ref(0);
const startInChannelTimer = () => {inChannelTimer.value && clearInterval(inChannelTimer.value);inChannelTimer.value = setInterval(() => {timeCount.value++;// console.log('%c通话计时开启中...', 'color:green', timeCount);}, 1000);
};
//转换为可直接渲染的时间
const formatTime = computed(() => {const m = Math.floor(timeCount.value / 60);const s = timeCount.value % 60;const h = Math.floor(m / 60);const remMin = m % 60;return `${h > 0 ? h + ':' : ''}${remMin < 10 ? '0' + remMin : remMin}:${s < 10 ? '0' + s : s}`;
});
//频道监听
const addListeners = () => {state.engine.addListener('JoinChannelSuccess', (channel, uid, elapsed) => {console.info('JoinChannelSuccess', channel, uid, elapsed);state.isJoined = true;});state.engine.addListener('UserJoined', (uid, elapsed) => {console.info('UserJoined', uid, elapsed);state.remoteUid = uid;});state.engine.addListener('UserOffline', (uid, reason) => {console.info('UserOffline', uid, reason);state.remoteUid = '';state.isJoined = false;leaveChannel();});state.engine.addListener('LeaveChannel', (stats) => {console.info('LeaveChannel', stats);state.isJoined = false;state.remoteUid = '';});
};
//保持屏幕常亮
uni.setKeepScreenOn({keepScreenOn: true,
});
//初始化频道实例
const initEngine = async () => {console.log('>>>>>>>初始化声网RTC');state.engine = await RtcEngine.create(AGORA_APP_ID);addListeners();if (uni.getSystemInfoSync().platform === 'android') {await permision.requestAndroidPermission('android.permission.RECORD_AUDIO');await permision.requestAndroidPermission('android.permission.CAMERA');}await state.engine.enableVideo();await state.engine.startPreview();await state.engine.setChannelProfile(ChannelProfile.LiveBroadcasting);await state.engine.setClientRole(ClientRole.Broadcaster);//设置频道麦克风为扬声器模式await state.engine.setDefaultAudioRoutetoSpeakerphone(true);await joinChannel();
};//加入频道
const joinChannel = async () => {let { accessToken, agoraUserId } =await agoraChannelStore.requestRtcChannelToken();console.log('>>>>>>频道token请求完成',accessToken,agoraUserId,channelName.value);(await state.engine) &&state.engine.joinChannel(accessToken, channelName.value, null, agoraUserId);startInChannelTimer();
};
//挂断
const leaveChannel = async () => {(await state.engine) && state.engine.leaveChannel();uni.navigateBack({ delta: 1 });//设置本地状态为闲置agoraChannelStore.updateLocalStatus(CALLSTATUS.idle);uni.showToast({icon: 'none',title: `通话结束【${formatTime.value}`,});
};
//切换摄像头
const onSwitchCamera = () => {state.engine &&state.engine.switchCamera().then(() => {state.isSwitchCamera = !state.isSwitchCamera;}).catch((err) => {console.warn('switchCamera', err);});
};
//切换扬声器
const onSwitchSperkerPhone = async () => {try {(await state.engine) &&state.engine.setEnableSpeakerphone(!state.isSwitchSperkerPhone);state.isSwitchSperkerPhone = !state.isSwitchSperkerPhone;} catch (error) {uni.showToast({ icon: 'none', title: '扬声器切换失败!' });}
};
//开启关闭本地麦克风采集
const onSwitchLocalMicPhone = async () => {try {(await state.engine) &&state.engine.muteLocalAudioStream(!state.isMuteLocalAudioStream);state.isMuteLocalAudioStream = !state.isMuteLocalAudioStream;} catch (error) {uni.showToast({ icon: 'none', title: '开关本地麦克风采集失败!' });}
};
//开启关闭本地视频流采集
const onSwitchLocalCameraOpened = async () => {try {(await state.engine) &&state.engine.enableLocalVideo(!state.isSwitchLocalCameraOpened);state.isSwitchLocalCameraOpened = !state.isSwitchLocalCameraOpened;} catch (error) {uni.showToast({ icon: 'none', title: '开关本地摄像头采集失败!' });}
};onLoad(() => {console.log('+++++++singleCall onLoad');initEngine();
});
onUnload(() => {state.engine && state.engine.destroy();state.isJoined = false;//卸载组件清除通话计时//清除通话计时inChannelTimer.value && clearInterval(inChannelTimer.value);
});
</script>

核心的流展示则是 Agora-UniApp 原生插件提供的RtcSurfaceView组件通过该组件进行本地流和远端流的展示。

在 nvue 组件中提几个点,可以关注一下。

  • 安卓机型,在发布本地流之前需要拿到用户关于录音以及摄像头的授权,否则无法正常的进行推流展示。具体的授权 js 调用插件,关注wa-permission这个插件。
  • 默认音视频通话会跟随系统息屏时间自动息屏,不希望息屏则可以调用 uni-app 提供的 apiuni.setKeepScreenOn({ keepScreenOn: true, });
  • 引入原生插件后必须打包为自定义调试基座才可以看到具体的效果,否则不会展示画面。

到这里可视页面的相关代码以及所需配置介绍暂时告一段落。
下面再看下邀请相关逻辑。

step6:关于 callKit 邀请相关逻辑的介绍。

如果作为邀请方也就是音视频功能的发起方,我们如何使用 callKit 内的代码完成这一动作?

<template><view><uv-popup ref="invitePopup" mode="bottom" round="10"><view class="invite_btn_box"><textclass="invite_func_btn"@click="sendAvCallMessage(CALL_TYPES.SINGLE_VIDEO)">视频通话</text><textclass="invite_func_btn"@click="sendAvCallMessage(CALL_TYPES.SINGLE_VOICE)">语音通话</text><text class="invite_func_btn invite_func_btn_cannel" @click="onCannel">取消</text></view></uv-popup></view>
</template>
<script setup>
import { ref, inject } from 'vue';
import useAgoraChannelStore from '@/components/emCallKit/stores/channelManger';
import { CALL_TYPES } from '@/components/emCallKit/contants';
import onFeedTap from '@/utils/feedTap';
const agoraChannelStore = useAgoraChannelStore();
const injectTargetId = inject('targetId');
const invitePopup = ref(null);
const openInvitePopup = () => {invitePopup.value.open();
};
const closeInvitePopup = () => {invitePopup.value.close();
};
const onCannel = () => {onFeedTap && onFeedTap();closeInvitePopup();
};
const sendAvCallMessage = async (callType) => {onFeedTap && onFeedTap();try {await agoraChannelStore.sendInviteMessage(injectTargetId.value, callType);uni.navigateTo({url: '/pages/emCallKitPages/alertScreen',});} catch (error) {console.log('>>>>通话邀请发起失败', error);uni.showToast({icon: 'none',title: '通话发起失败',});} finally {closeInvitePopup();}
};defineExpose({openInvitePopup,
});
</script>

在实际的 Demo 中增加了一个inviteAvcall.vue组件在外层点击某个 icon 时展示该 Popup 组件,弹出视频邀请或音频邀请的选项。
效果如下:

IMG_68B260790344-1.jpeg
点击时传入对应的类型邀请信令发送给要邀请的目标一条文本邀请信息。

而多人音视频模式下,邀请下则不需要弹出待接听页面,而是进入勾选要发送邀请信息的成员页面,发送邀请并创建频道并加入即可,就像这样。

const inviteAvcallComp = ref(null);
const selectAvcallType = () => {closeAllModal();if (injectChatType.value === 'groupChat') {uni.navigateTo({url: `/pages/emCallKitPages/inviteMembers?groupId=${injectTargetId.value}`,});} else {inviteAvcallComp.value && inviteAvcallComp.value.openInvitePopup();}
};

页面效果展示

IMG_68B260790344-1.jpeg

IMG_1761.png

IMG_1760.PNG

相关链接

环信 uni-app 文档地址

本文源码地址

声网音视频插件资料相关地址

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/123127.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

箭头函数(arrow function)与普通函数之间的区别是什么?

聚沙成塔每天进步一点点 ⭐ 专栏简介⭐ 语法简洁性&#xff1a;⭐ this 的绑定&#xff1a;⭐ 不能用作构造函数&#xff1a;⭐ 没有 arguments 对象&#xff1a;⭐ 不适用于方法&#xff1a;⭐ 写在最后 ⭐ 专栏简介 前端入门之旅&#xff1a;探索Web开发的奇妙世界 记得点击上…

如何解决GitHub 访问不了?小白教程

GitHub 是全球最大的代码开源平台&#xff0c;小伙伴们平时都喜欢在那里找一些优质的开源项目来学习&#xff0c;以提升自己的编程技能。 但是很多小白初探GitHub 发现访问不了&#xff0c;不能访问 通过一下方法绕过这堵墙&#xff0c;成功下载 GitHub 上的项目。过程非常简单…

公园气象站:用科技力量,感知气象变化

在城市的喧嚣中&#xff0c;公园成为人们休闲娱乐的宁静之地。而在这些公园中的公园气象站静静地矗立着&#xff0c;不仅为公园的日常运营提供着重要数据&#xff0c;还在为游客的安全保驾护航。 用科技力量&#xff0c;感知气象变化 科技的创新为气象监测提供了更为精准的手…

基于SNAT+DNAT发布内网K8S及Jenkins+gitlab+Harbor模拟CI/CD的综合项目

目录 项目名称 项目架构图 项目环境 项目概述 项目准备 项目步骤 一、修改每台主机的ip地址&#xff0c;同时设置永久关闭防火墙和selinux&#xff0c;修改好主机名&#xff0c;在firewalld服务器上开启路由功能并配置snat策略。 1. 在firewalld服务器上配置ip地址、设…

float浮动布局大战position定位布局

华子目录 布局方式普通文档流布局浮动布局&#xff08;浮动主要针对与black&#xff0c;inline元素&#xff09;float属性浮动用途浮动元素父级高度塌陷 position属性定位篇相对定位&#xff08;relative为属性值&#xff0c;配合left属性&#xff0c;和top属性使用&#xff09…

医院空调冷热源设计方案VR元宇宙模拟演练的独特之处

作为一个备受关注的技术-元宇宙&#xff0c;毋庸置疑的是&#xff0c;因为建筑本身契合了时尚、前卫、高端、虚拟、科幻、泛在、协作、互通等元素特征&#xff0c;因此在建筑行业更需要元宇宙&#xff0c;以居民建筑环境冷热源设计来说&#xff0c;元宇宙会打破既定的现实阻碍和…

对象存储 OSS

大家好 , 我是苏麟 , 今天聊聊OSS . 这里使用阿里云的OSS对象存储. 首先大家得有一个阿里云账号 , 注册大家都会 这里不多介绍 . 阿里云官网 : 阿里云登录页 (aliyun.com) 首页产品目录下存储集合里对象存储OSS 进入对象存储OSS页面 点击管理控制台(新用户应该有免费试用期的)…

typeof 在TypeScript中和JavaScript中的区别

前言 在TypeScript中和JavaScript中都有typeOf&#xff0c;但是作用用法却大有不同。 js的typeof 一、typeof用来判断数据类型返回结果&#xff1a; 基本数据类型&#xff1a;string&#xff0c;number&#xff0c;boolean,undefined 引用数据类型&#xff1a;object …

达梦数据库管理用户和创建用户介绍

概述 本文主要对达梦数据库管理用户和创建用户进行介绍和总结。 1.管理用户介绍 1.1 达梦安全机制 任何数据库设计和使用都需要考虑安全机制&#xff0c;达梦数据库采用“三权分立”或“四权分立”的安全机制&#xff0c;将系统中所有的权限按照类型进行划分&#xff0c;为每…

ActiveReportsJs 账票印刷

参考资料 官方文档 一. HTML部分 在页面上添加了Loading效果&#xff0c;账票印刷开始时显示Loading效果&#xff0c;印刷结束后隐藏Loading效果。ar-js-core.js是核心文件ar-js-pdf.js用来印刷PDFar-js-xlsx.js用来印刷EXCELar-js-locales.js用来设置语言 <!DOCTYPE htm…

9.3.3网络原理(网络层IP)

一.报文: 1.4位版本号:IPv4和IPv6(其它可能是实验室版本). 2.4位首部长度:和TCP一样,可变长,带选项,单位是4字节. 3.8位服务类型 4.16位总长度:IP报头 IP载荷 传输层是不知道载荷长度的,需要网络层来计算. IP报文 - IP报头 IP载荷 TCP报文 TCP载荷 IP载荷(TCP报文) …

MySQL——数据类型以及对表结构的修改

MySQL的数据类型 刚才我们在创建表的时候&#xff0c;说到了一个字段类型&#xff0c;所谓的字段类型就是这个字段能存放的数据的数据类型&#xff0c;在MySQL中有以下几种数据类型&#xff1a; 数据类型 大小&#xff08;字节&#xff09; 用途 格式 INT 4 整数 FLOAT…

QT的介绍和优点,以及使用QT初步完成一个登录界面

QT介绍 QT主要用于图形化界面的开发&#xff0c;QT是基于C编写的一套界面相关的类库&#xff0c;进程线程库&#xff0c;网络编程的库&#xff0c;数据库操作的库&#xff0c;文件操作的库…QT是一个跨平台的GUI图形化界面开发工具 QT的优点 跨平台&#xff0c;具有较为完备…

http实现文件分片下载

文章目录 检测是否支持HTTP Range 语法Range请求cURL示例单一范围多重范围条件式分片请求 Range分片请求的响应文件整体下载文件分片下载文本下载图片下载封装下载方法 HTTP分片异步下载是一种下载文件的技术&#xff0c;它允许将一个大文件分成多个小块&#xff08;分片&#…

c++异步框架workflow分析

简述 workflow项目地址 &#xff1a; https://github.com/sogou/workflow workflow是搜狗开源的一个开发框架。可以满足绝大多数日常服务器开发&#xff0c;性能优异&#xff0c;给上层业务提供了易于开发的接口&#xff0c;却只用了少量的代码&#xff0c;举重若轻&#xff…

java+ssm+mysql电梯管理系统

项目介绍&#xff1a; 使用javassmmysql开发的电梯管理系统&#xff0c;系统包含管理员&#xff0c;监管员、安全员、维保员角色&#xff0c;功能如下&#xff1a; 管理员&#xff1a;系统用户管理&#xff08;监管员、安全员、维保员&#xff09;&#xff1b;系统公告&#…

【算法训练-链表 四】【删除】:删除链表的倒数第N个节点、删除有序链表中的重复元素、删除有序链表中的重复元素II

废话不多说&#xff0c;喊一句号子鼓励自己&#xff1a;程序员永不失业&#xff0c;程序员走向架构&#xff01;本篇Blog的主题是【删除有序链表中的重复元素】&#xff0c;使用【链表】这个基本的数据结构来实现&#xff0c;这个高频题的站点是&#xff1a;CodeTop&#xff0c…

分布式实时仿真系统-反射内存的应用

为了使分布式实时仿真系统(一个典型代表就行飞行模拟器)达到逼真的仿真效果&#xff0c;在系统内部&#xff0c;往往不仅需要对各种数据模型进行实时解算&#xff0c;而且需要一个延迟时间极低的确定性网络在系统之间传递数据&#xff0c;这样才能让各个子系统之间协调一致地工…

【Cisco Packet Tracer】交换机划分Vlan实验

&#x1f490; &#x1f338; &#x1f337; &#x1f340; &#x1f339; &#x1f33b; &#x1f33a; &#x1f341; &#x1f343; &#x1f342; &#x1f33f; &#x1f344;&#x1f35d; &#x1f35b; &#x1f364; &#x1f4c3;个人主页 &#xff1a;阿然成长日记 …

优雅编码!Java与MongoDB的创新数据库架构

随着现代应用程序对数据存储和处理需求的不断增加&#xff0c;开发人员需要寻找更具创新性和灵活性的数据库架构来满足这些需求。在这样的背景下&#xff0c;Java与MongoDB的结合为开发人员提供了一种创新的数据库架构&#xff0c;为应用程序带来了无限可能。下面将探讨Java与M…