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

news2024/12/26 14:43:59

前序文章:

环信 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: '', //频道token
        agoraUserId: '', //频道用户id,
        callType: CALL_TYPES.SINGLE_VOICE, //0 语音 1 视频 2 多人音视频
        callId: null, //会议ID
        channelUsers: {}, //频道内用户
        callerDevId: '', //主叫方设备ID
        calleeDevId: '', //被叫方设备ID
        callerIMName: '', //主叫方环信ID
        calleeIMName: '', //被叫方环信ID
        groupId: '', //群组ID
      },
      //被邀请对象 单人为string 多人为array
      inviteTarget: 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: '', //频道token
        agoraUid: '', //频道用户id
        callType: CALL_TYPES.SINGLE_VOICE, //0 语音 1 视频 2 多人音视频
        callId: null, //会议ID
        channelUsers: {}, //频道内用户
        callerDevId: '', //主叫方设备ID
        calleeDevId: '', //被叫方设备ID
        confrontId: '', //要处理的目标ID
        callerIMName: '', //主叫方环信ID
        calleeIMName: '', //被叫方环信ID
        groupId: '', //群组ID
      };
      this.callKitStatus.inviteTarget = null;
      this.callKitTimer && clearTimeout(this.callKitTimer);
    },
    //更新localStatus
    updateLocalStatus(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">
    <!-- 视频视图 -->
    <view
      class="rtc_view_container"
      v-if="callKitStatus.channelInfos.callType === CALL_TYPES.SINGLE_VIDEO"
    >
      <view class="local_container">
        <rtc-surface-view
          v-if="state.engine"
          class="local_view_stream"
          :uid="0"
          :zOrderMediaOverlay="true"
        ></rtc-surface-view>
      </view>
      <view class="remote_container">
        <rtc-surface-view
          class="remote_view_stream"
          :uid="state.remoteUid"
        ></rtc-surface-view>
      </view>
    </view>
    <!-- 语音视图 -->
    <view
      class="rtc_voice_container"
      v-if="callKitStatus.channelInfos.callType === CALL_TYPES.SINGLE_VOICE"
    >
      <view class="circleBodyView">
        <image
          class="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">
          <image
            class="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">
          <image
            class="circleImg"
            :src="
              state.isSwitchSperkerPhone
                ? '/static/emCallKit/icon_video_speaker.png'
                : '/static/emCallKit/icon_video_speakerno.png'
            "
          ></image>
          <text class="hint">扬声器</text>
        </view>
        <view
          v-if="callKitStatus.channelInfos.callType === CALL_TYPES.SINGLE_VIDEO"
          class="circleBox"
          @click="onSwitchLocalCameraOpened"
        >
          <image
            class="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">
          <image
            class="circleImg"
            src="/static/emCallKit/icon_video_cancel.png"
          ></image>
          <text class="hint">挂断</text>
        </view>
      </view>
      <image
        v-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">
        <text
          class="invite_func_btn"
          @click="sendAvCallMessage(CALL_TYPES.SINGLE_VIDEO)"
          >视频通话</text
        >
        <text
          class="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.coloradmin.cn/o/979513.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

毫米波水位监测仪:实时监测水体水位变化

水位监测是一项关键的技术&#xff0c;用于实时监测水体的水位变化&#xff0c;对于水利工程、自然灾害预防和水资源管理都具有重要的意义。通过在各关键节点安装毫米波水位监测仪&#xff0c;可对水位情况进行实时监测&#xff1b;当水位超过阈值时&#xff0c;智能监测仪器将…

一份超预期的期中成绩,拨开百果园“高价值迷雾”

文 | 螳螂观察 作者 | 青月 步入2023年&#xff0c;经济复苏、消费增长趋势显现&#xff0c;但实体店还未完全突破桎梏。 不过&#xff0c;即使是在这样的市场环境中&#xff0c;年初成功“上岸”&#xff0c;估值一度达百亿的百果园&#xff0c;依旧交出了一份营收净利双增…

二蛋赠书一期:《快捷学习Spring》

文章目录 前言活动规则参与方式本期赠书《快捷学习Spring》关于本书作者介绍内容简介读者对象 结语 前言 大家好&#xff01;我是二蛋&#xff0c;一个热爱技术、乐于分享的工程师。在过去的几年里&#xff0c;我一直通过各种渠道与大家分享技术知识和经验。我深知&#xff0c…

【C++】函数重载 ④ ( 函数指针定义的三种方式 | 直接定义函数指针 | 通过 函数类型 定义 函数指针 | 通过 函数指针类型 定义 函数指针 )

文章目录 一、函数指针定义方法1、直接定义函数指针2、通过 函数类型 定义 函数指针3、通过 函数指针类型 定义 函数指针4、代码示例 - 不同方式定义函数指针 博客总结 : 重载函数 : 使用 相同 的 函数名 , 定义 不同 的 函数参数列表 ;判定标准 : 只有 函数参数 的 个数 / 类…

给抖音达人推商品需要注意什么?抖店商家注意了,教你几个技巧

我是王路飞。 找达人带货这种玩法&#xff0c;虽然商家要给带货达人佣金&#xff0c;相当于你的利润变少了。 但是你要明白一件事&#xff0c;我们做抖店也不是做着玩的&#xff0c;而是奔着长线去玩的&#xff0c;所以长久稳定才是我们需要的。 而相比较自然流量&#xff0…

SecureCRT安装、汉化、上传、美化

文章目录 SecureCRT安装、汉化、美化一、SecureCRT介绍二、SecureCRT下载三、SecureCRT汉化四、SecureCRT连接五、SecureCRT上传第一种方法&#xff1a;用SFTP 传输文件第二种方法&#xff1a;WinSCP传输文件 六、SecureCRT美化 SecureCRT安装、汉化、美化 一、SecureCRT介绍 …

Source Insight 宏-添加单行的c注释

今天写代码的时候突然想到在代码的上一空行添加一对 /* */ 来添加注释&#xff0c;或者单独注释一行代码&#xff0c;而且是用 c 的注释方式&#xff0c;即使用 /**/&#xff0c;如想要在光标处添加 /**/ 或者注释掉光标所在的行&#xff0c;如&#xff1a; 实际的效果就是这样…

小程序开发一个多少钱啊

小程序开发费用 小程序作为一种流行的移动应用形式&#xff0c;具有广阔的市场前景和商业机会。然而&#xff0c;在考虑开发小程序时&#xff0c;了解相关费用是至关重要的。以下是关于小程序开发费用及其相关成本的详细解析&#xff1a; 1. 小程序认证费用&#xff1a; 开发…

【vue】使用无障碍工具条(详细)

引入&#xff1a;使用的是太阳湾的无障碍工具条&#xff0c;代码地址&#xff1a;https://gitee.com/tywAmblyopia/ToolsUI 具体步骤&#xff1a;下载代码后&#xff0c;将其中的 canyou 文件夹拖入 vue 项目中的 public 文件夹中&#xff1b; 上图是在项目目录中的样子&#…

Nginx - 根据请求参数路由进行不同的响应

文章目录 需求思路 需求 业务有一个统一入口 /api/biz?type1 /api/biz/type2需要对不同的接口实现流控 最常见的是通过location进行路径匹配的时候&#xff0c;但是无法使用正则表达一起捕获这个路径和querstring的参数。如果我们想通过URL里面的Query String进行不同的rew…

mybatisplus多租户配置

概述 当前mybatisPlus版本 <dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.5.3.2</version> </dependency>jdk版本&#xff1a;17 springboot版本&#xff1a;…

macOS外接显示器切换窗口卡顿的问题

背景 最近发现在使用外接显示器的情况下&#xff0c;切换窗口鼠标经常会卡一下卡一下的。 过程 由于我个人有安装自动切换输入法的工具&#xff08;Input Source Pro&#xff09;&#xff0c;我以为是在不同窗口中切换了输入法导致的卡顿&#xff0c;我就关闭了这个软件&…

YOLO目标检测——VOC2007数据集+已标注VOC格式标签下载分享

VOC2007数据集是一个经典的目标检测数据集&#xff0c;该数据集包含了20个常见的目标类别&#xff0c;涵盖了人、动物、交通工具等多个领域&#xff0c;共同11220图片。使用lableimg标注软件标注&#xff0c;标注框质量高&#xff0c;标签格式为VOC格式&#xff08;即xml标签&a…

随心记录0816

1. foce相关方法 下面这张图☞的都是uvm_hdl_force和uvm_hdi_deposit 2.post randomize函数的使用方法 【验证小白】随机中使用post_randomize的正确姿势_尼德兰的喵的博客-CSDN博客 3. 4. process用法 systemverilog的process类 | 骏的世界 (lujun.org.cn) 5.uvm_re_mat…

被百度判定为低质量网站了!如何整改?

我是卢松松&#xff0c;点点上面的头像&#xff0c;欢迎关注我哦&#xff01; 先说结论&#xff1a;接受现实&#xff0c;不要幻想百度恢复了! 百度自9月初大批量删除百度资源平台权限以来&#xff0c;几乎90%(未经证实**&#xff0c;但数量确实不小)的网站都被取消了权限&am…

【正点原子STM32连载】 第二十九章 DMA实验 摘自【正点原子】APM32F407最小系统板使用指南

1&#xff09;实验平台&#xff1a;正点原子stm32f103战舰开发板V4 2&#xff09;平台购买地址&#xff1a;https://detail.tmall.com/item.htm?id609294757420 3&#xff09;全套实验源码手册视频下载地址&#xff1a; http://www.openedv.com/thread-340252-1-1.html# 第二…

【PowerQuery】PowerQuery学习路径

PowerQuery这么好,怎么去学习呢?相信很多初读本书的朋友迫切的希望了解整个PowerQuery全景知识和它提供的相应的功能。但是对于PowerQuery来说,一开始就会进行自定义函数的构建当然也是不可能的,这里有相应的学习路径来进行由浅入深的学习,帮助读者更好的理解PowerQuery的…

12V/24V/48V 直流DC电源浪涌保护方案图 超齐全

直流DC电源端口浪涌过压防护一直都是很多新老电子工程师关注的方案之一。不管是电源端口浪涌防护还是信号接口静电保护&#xff0c;浪涌静电防护&#xff0c;找东沃&#xff0c;电路保护不迷路&#xff01;东沃电子专注于研发、生产、销售静电保护二极管&#xff08;ESD&#x…

natapp 内网穿透 获取网页信息相关内容简介

1 首选介绍natapp 内网穿透进入官网 https://natapp.cn/ 购买或者使用免费隧道 协议使用web ,填写本地地址和web端可访问的端口 进入exe文件的目录 运行 natapp -authtokenxxxxxx 剩下获取微信用户信息 第一步获取code 这里需要设置授权域名 这里可以设置上 natapp给分配的地…

WebSocket原理简介

慢聊Go之GoLang中使用Gorilla Websocket&#xff5c;Go主题月 - 掘金 (juejin.cn) 【Go项目】24. WebSocket 基本原理_哔哩哔哩_bilibili 1.http和socket的区别 1&#xff09; http要先给服务器发请求&#xff0c;然后才会得到响应&#xff0c;基本是一问一答式。 而socke…