声网 agora 音视频通话

721 阅读6分钟

前言

  • 本文基于vue、Element-ui、Agora Web SDK4.15.1版本实现音视频通话。
  • Agora Web SDK 是通过 HTML 网页加载的 JavaScript 库。Agora Web SDK 库在网页浏览器中调用 API 建立连接,控制音视频通话和直播服务。

Agora

  1. Agora官网 创建账号
  2. 项目管理-创建项目-填写名称、场景选音视频通话
  3. 创建完后-配置-输入房间名称创建临时token
  4. 项目主用到是AppId、房间名称、token 缺一不可

Web SDK 常用事件

AgoraRTC 是 Agora Web SDK 中所有可调用方法的入口

AgoraRTC

createClient 创建客户端

该方法用于创建客户端,在每次会话里仅调用一次
const config={
    mode: "live",// live直播场景(主播) rtc通信场景(群聊)
    codec: "vp8",// VP8或H.264 编码
    role: "host",// "host"(主播)和 "audience"(观众)
}
AgoraRTC.createClient(config) //返回实例

checkSystemRequirements 适配

该方法检查 Web SDK 对正在使用的浏览器的适配情况,创建客户端前调用
AgoraRTC.checkSystemRequirements() //返回true适配 false不适配

Client 客户端实例

join 加入房间

该方法让用户加入通话房间频道,在同一个频道内的用户可以互相通话。参数分别appId、房间名、token,

agoraClient.join(appId, channel, token)

加入房间时触发以下回调

// SDK 与服务器的连接状态发生改变回调,返回当前的连接状态、之前的连接状态
agoraClient.on("connection-state-change",(curState,revState)=>{
  console.log(curState,revState)
})

// 加入频道回调,返回房间用户信息
agoraClient.on("user-joined", (user)=>{
  console.log(user)
});

leave 离开房间

该方法用户离开房间频道即挂断或退出通话

agoraClient.leave()

退出房间时触发以下回调

// SDK 与服务器的连接状态发生改变回调,返回当前的连接状态、之前的连接状态
agoraClient.on("connection-state-change",(curState,revState)=>{
  console.log(curState,revState)
})

// 远端用户离开频道回调,返回用户信息、离线原因
agoraClient.on("user-left",(user,reason)=>{
  console.log(user,reason)
})

publish 发布本地音视频流

agoraClient.publish(audioTrack,videoTrack)//音频轨道、视频轨道

发布后触发以下回调

// 如果用户加入频道时频道内已经有其他用户的音视频轨道,订阅远程用户发布音视频流回调,返回用户信息、媒体类型
agoraClient.on("user-published", (user,mediaType)=>{
  agoraClient.subscribe(user, mediaType)//订阅远端用户的音视频轨道
  if (mediaType === "video") { 
  user.videoTrack.play("xxx")} 
  if (mediaType === "audio") {  
  user.audioTrack.play()}
});

unpublish 取消发布本地音视频流

// 要取消发布的轨道。如果留空,会将所有发布过的音视频轨道取消发布
agoraClient.unpublish()

取消发布后触发以下回调

// 远端用户取消发布视频流回调,返回用户信息、媒体类型
agoraClient.on("user-unpublished", (user,mediaType)=>{
  agoraClient.unsubscribe(user, mediaType)
});

完整代码

基于element-ui 实现视频通话

yarn add agora-rtc-sdk-ng@4.15.1
yarn add element-ui
或
npm install agora-rtc-sdk-ng@4.15.1
npn install element-ui

agora.js

这边是在mixins下创建agora.js,方便日后维护

import AgoraRTC from "agora-rtc-sdk-ng";

export default {
  data() {
    return {
      isJoined: false,  // 是否加入频道
      isCameraEnabled: false,  // 摄像头状态
      isMicrophoneEnabled: false,  // 麦克风状态
      agoraClient: null, // Agora实例
      uid: null,// 频道id
      // 本地音视频流
      localTracks: {
        videoTrack: null,
        audioTrack: null
      },
      remoteUsers: [],  // 远程用户列表
      config: {
        mode: "live",// live直播场景(主播) rtc通信场景(群聊)
        codec: "vp8",// VP8或H.264 编码
        role: "host",// "host"(主播)和 "audience"(观众)
        // areaCode: [],// 指定服务器的访问区域,仅支持指定单个访问区域
      }
    }
  },
  methods: {
    /**
     * 加入频道(房间)
     * @param {String} param.appId 
     * @param {String} param.channel 频道房间号
     * @param {String} param.token 令牌
     * @param {String} param.uid 指定用户的ID(可选)
     */
    async joinRoom(param) {
      //当前浏览器是否兼容Web SDK
      const Adapter = await AgoraRTC.checkSystemRequirements()
      if (Adapter) {
        // 创建 AgoraRTC 客户端实例
        this.agoraClient = await AgoraRTC.createClient(this.config);
        // 监测远程用户事件
        this.agoraClient.on("user-joined", this.joined);
        this.agoraClient.on("user-left", this.left);
        this.agoraClient.on("user-published", this.handleUserPublished);
        this.agoraClient.on("user-unpublished", this.handleUserUnpublished);
        const { appId, channel, token } = param;
        // 加入频道,返回用户的唯一标识符(uid)
        this.uid = await this.agoraClient.join(appId, channel, token)
        this.isJoined = true // 加入频道标记
        this.getEquipment() // 获取用户媒体权限和设备
        this.onCameraChange() // 监听摄像头设备变化,并根据变化情况进行处理
        this.onMicrophoneChange() // 监听麦克风设备变化,并根据变化情况进行处理
      } else {
        this.$message.warning("当前浏览器不兼容 WebSDK")
      }
    },
    /**
     * 离开频道(房间)
     */
    async leaveChannel() {
      // 停止本地播放的音视频
      for (let trackName in this.localTracks) {
        let track = this.localTracks[trackName];
        if (track) {
          track.stop();
          track.close();
          this.localTracks[trackName] = null;
        }
      }
      await this.agoraClient.unpublish();//停止本地流的发布
      await this.agoraClient.leave();// 离开频道
      this.remoteUsers = [];// 清空远程用户
      this.isJoined = false,// 改变频道标记
      this.agoraClient = null;// 清空Agora客户端实例
    },
    /**
     * 远程用户加入频道回调
     * @param {Object} user 远程用户信息
     */
    joined(user) {
      // 在远程用户列表中添加新加入的用户
      this.remoteUsers.push({
        uid: user.uid,// 用户的唯一标识符
        hasAudio: false,// 是否存在音频流,默认为false
        hasVideo: false,// 是否存在视频流,默认为false
      })
    },
    /**
     * 远端用户离开频道回调
     * @param {Object} user 远程用户信息
     * @param {String} reason 远程用户离线的原因
     */
    left(user, reason) {
      console.log("user-left", user, reason);
    },
    /**
     * 远程用户发布音视频流回调(进来两次分别音频和视频)
     * @param {Object} user 远程用户实例
     * @param {String} mediaType 媒体类型
     */
    async handleUserPublished(user, mediaType) {
      await this.agoraClient.subscribe(user, mediaType)//订阅远端用户的音视频轨道
      // 遍历远程用户列表
      this.remoteUsers.forEach(item => {
        if (item.uid == user.uid) {
          if (mediaType === "video") {
            item.hasVideo = true; // 标记远程用户存在视频流
            // 播放远程用户的视频流
            user.videoTrack.play(`remote-stream-${item.uid}`, {
              fit: "fill",
            });
          }
          if (mediaType === "audio") {
            item.hasAudio = true; // 标记远程用户存在音频流
            user.audioTrack.play();// 播放远程用户的音频流
          }
        }
      });
    },

    /**
     * 远程用户取消发布音视频流回调
     * @param {Object} user 远程用户
     * @param {String} mediaType 媒体类型
     */
    async handleUserUnpublished(user, mediaType) {
      await this.agoraClient.unsubscribe(user, mediaType)//取消订阅远端用户的音视频轨道
      this.remoteUsers = this.remoteUsers.filter(stream => stream.uid != user.uid)
    },

    /* 监测本地摄像头被添加或移除 */
    onCameraChange() {
      AgoraRTC.onCameraChanged = async (changedDevice) => {
        // 摄像头设备为活动。
        if (changedDevice.state === "ACTIVE") {
          const videoTrack = await AgoraRTC.createCameraVideoTrack()
          // 1.判断是否存在已有的视频轨道
          if (this.localTracks.videoTrack) {
            const currentDeviceLabel = this.localTracks.videoTrack.getTrackLabel()
            // 2.当前设备为已有设备时,切换到另一个摄像头设备
            if (changedDevice.device.label === currentDeviceLabel) {
              const oldCameras = await AgoraRTC.getCameras();
              oldCameras[0] && this.localTracks.videoTrack.setDevice(oldCameras[0].deviceId);
            } else {
              // 3.当前设备为不同的设备时,先取消发布已有轨道,再发布新的轨道
              await this.agoraClient.unpublish(this.localTracks.videoTrack)
              this.localTracks.videoTrack = videoTrack
              this.localTracks.videoTrack.play('local-video', { fit: "fill" });
              await this.agoraClient.publish(this.localTracks.videoTrack);
            }
          }
          else {
            // 4.当前没有视频轨道,直接发布新的轨道
            this.localTracks.videoTrack = videoTrack
            this.localTracks.videoTrack.play('local-video', { fit: "fill" });
            await this.agoraClient.publish(this.localTracks.videoTrack);
          }
          this.isCameraEnabled = true
        } else {
          this.isCameraEnabled = false
        }
      }
    },

    /* 监测本地麦克风被添加或移除 */
    async onMicrophoneChange() {
      AgoraRTC.onMicrophoneChanged = async (changedDevice) => {
        // 麦克风设备为活动
        if (changedDevice.state === "ACTIVE") {
          // 创建麦克风音频轨道并播放
          this.localTracks.audioTrack = await AgoraRTC.createMicrophoneAudioTrack()
          this.localTracks.audioTrack.play()
          // 发布麦克风音频轨道
          await this.agoraClient.publish(this.localTracks.audioTrack);
          this.isMicrophoneEnabled = true
        } else {
          this.isMicrophoneEnabled = false
        }
      }
    },

    /* 检查设备是否可用(常用) */
    async getEquipment() {
      // 检查摄像头设备是否可用
      const dev = await AgoraRTC.getDevices({ skipPermissionCheck: false })
      const hasCamera = dev.some(device => device.kind === 'videoinput');
      const hasMicrophone = dev.some(device => device.kind === 'audioinput')
      if (hasCamera) {
        // 创建一个视频轨道对象并播放、发布到远程
        this.localTracks.videoTrack = await AgoraRTC.createCameraVideoTrack()
        this.localTracks.videoTrack.play('local-video', { fit: "fill" });
        await this.agoraClient.publish(this.localTracks.videoTrack)
        this.isCameraEnabled = true
      }
      if (hasMicrophone) {
        // 创建一个音频轨道对象并播放、发布到远程
        this.localTracks.audioTrack = await AgoraRTC.createMicrophoneAudioTrack()
        this.localTracks.audioTrack.play();
        await this.agoraClient.publish(this.localTracks.audioTrack)
        this.isMicrophoneEnabled = true
      }
      // 对没有的设备相应提示
      if (!hasCamera || !hasMicrophone) {
        const message = !hasCamera && hasMicrophone ?
          "检测不到可用的摄像头设备" :
          hasCamera && !hasMicrophone ?
            "检测不到可用的麦克风设备" :
            "检测不到可用的麦克风、摄像头设备"
        this.$message.warning(message)
      }
    },

    /*检查设备是否可用(Https环境下才有效)*/
    async getEquipmentHttps() {
      try {
        // 检查摄像头设备是否可用
        const devices = await navigator.mediaDevices.enumerateDevices();
        const hasCamera = devices.some(device => device.kind === 'videoinput');
        const hasMicrophone = devices.some(device => device.kind === 'audioinput');
        // 请求麦克风和摄像头权限
        const stream = await navigator.mediaDevices.getUserMedia({ audio: hasMicrophone, video: hasCamera })
        const videoTrack = stream.getVideoTracks()[0];
        const audioTrack = stream.getAudioTracks()[0];
        if (videoTrack) {
          // 创建一个视频轨道对象并播放、发布到远程
          this.localTracks.videoTrack = await AgoraRTC.createCameraVideoTrack()
          this.localTracks.videoTrack.play('local-video', { fit: "contain" });
          await this.agoraClient.publish(this.localTracks.videoTrack)
          this.isCameraEnabled = true
        }
        if (audioTrack) {
          // 创建一个音频轨道对象并播放、发布到远程
          this.localTracks.audioTrack = await AgoraRTC.createMicrophoneAudioTrack()
          this.localTracks.audioTrack.play();
          await this.agoraClient.publish(this.localTracks.audioTrack)
          this.isMicrophoneEnabled = true
        }
      } catch (error) {
        this.$message.warning("检测不到可用的麦克风、摄像头设备")
      }
    },

    /**
     * 开关摄像头(有设备时)
     * 注意setEnabled和setMuted不能同时使用,否则报错
     * setEnabled和setMuted区别 前者恢复较慢(摄像头指示灯关闭),后者较快(摄像头指示灯不关闭)
     */
    async toggleCamera() {
      if (this.localTracks.videoTrack) {
        if (this.isCameraEnabled) {
          // 暂停发布视频到远程
          // await this.localTracks.videoTrack.setEnabled(false);
          await this.localTracks.videoTrack.setMuted(true);
        } else {
          // 继续发布视频到远程
          // await this.localTracks.videoTrack.setEnabled(true);
          await this.localTracks.videoTrack.setMuted(false);
        }
        this.isCameraEnabled = !this.isCameraEnabled;
      }
    },

    /**
     * 开关麦克风(有设备时)
     * 注意setEnabled和setMuted不能同时使用,否则报错
     * setEnabled和setMuted区别 前者恢复较慢,后者较快
     */
    async toggleMicrophone() {
      if (this.localTracks.audioTrack) {
        if (this.isMicrophoneEnabled) {
          // 暂停发布麦克风到远程
          // this.localTracks.audioTrack.setEnabled(false); 
          await this.localTracks.audioTrack.setMuted(true);
        } else {
          // 继续发布麦克风到远程
          // this.localTracks.audioTrack.setEnabled(true); 
          await this.localTracks.audioTrack.setMuted(false);
        }
        this.isMicrophoneEnabled = !this.isMicrophoneEnabled;
      }
    },
  },
  created() { },
  beforeDestroy() { }
}

封装video-call组件

<template>
  <div class="container">
    <el-dialog
      :title="title"
      v-drag
      :visible="visible"
      :close-on-click-modal="false"
      :destroy-on-close="false"
      :modal="false"
      :show-close="false"
      :append-to-body="false"
      :custom-class="isTheme ? 'Dark' : 'Light'"
      :fullscreen="fullscreen"
      width="20%"
      ref="dialogs"
      center
    >
      <template slot="title">
        <span>{{ title }}</span>
        <span style="margin-left: 5px" @click="onFullscreen">{{
          fullscreen ? "退出全屏" : "全屏"
        }}</span>
      </template>

      <div
        id="playContainer"
        :style="{ height: fullscreen ? '100%' : '200px' }"
      >
        <!-- 远程画面 -->
        <div
          :class="['remote', getVideoClass()]"
          v-for="item in remoteUsers"
          :key="item.uid"
          :id="'remote-stream-' + item.uid"
        >
          <img
            :src="item.hasAudio ? microphone2 : microphone1"
            :title="item.hasAudio ? '用户已开启麦克风' : '用户暂无麦克风'"
            class="microphone"
          />
          <img
            v-if="!item.hasVideo"
            title="用户暂无画面"
            :src="portrait"
            class="camera"
          />
        </div>
        <!-- 本地画面 -->
        <div
          id="local-video"
          :class="['remote', getVideoClass(), getLocalVideoClass()]"
        >
          <!-- <img
            :src="localTracks.audioTrack ? microphone2 : microphone1"
            class="microphone"
          /> -->
          <img v-if="!localTracks.videoTrack" :src="portrait" class="camera" />
        </div>
      </div>
      <!-- 功能 -->
      <div slot="footer">
        <el-button
          v-if="this.localTracks.audioTrack"
          circle
          size="mini"
          style="font-size: 16px"
          :icon="
            isMicrophoneEnabled
              ? 'el-icon-microphone'
              : 'el-icon-turn-off-microphone'
          "
          @click="toggleMicrophone"
        ></el-button>
        <el-button
          type="danger"
          circle
          icon="iconfont icon-telephone-hang-up"
          @click="onLeave"
        ></el-button>
        <el-button
          v-if="this.localTracks.videoTrack"
          :icon="
            [
              'iconfont',
              isCameraEnabled ? 'icon-shexiangtou' : 'icon-shexiangtouguanbi',
            ].join(' ')
          "
          circle
          size="mini"
          @click="toggleCamera"
        ></el-button>
      </div>
    </el-dialog>
  </div>
</template>

<script>
const WHITE = "#fff";
const BLACK = "#202124";
const GREY = "#ccc";
import microphone1 from "@/assets/image/microphone1.png";
import microphone2 from "@/assets/image/microphone2.png";
import portrait from "@/assets/image/portrait.png";
import agora from "@/mixins/agora_four.js";
export default {
  props: {
    title: {
      type: String,
      default: "视频通话",
    },
    paramsData: {
      type: Object,
      default: () => {
        return {};
      },
    },
    visible: {
      type: Boolean,
      default: false,
    },
    // 主题(默认浅色)
    isTheme: {
      type: Boolean,
      default: false,
    },
    bgColor: {
      type: String,
      default: GREY,
    },
  },
  components: {},
  mixins: [agora],
  watch: {
    fullscreen(val) {
      if (val) {
        this.regular();
      }
    },
  },
  data() {
    return {
      fullscreen: false, // 是否全屏
      portrait,
      microphone1,
      microphone2,
    };
  },
  methods: {
    onLeave() {
      this.leaveChannel(); //清空agora
      this.$emit("update:visible", false);
    },
    // 全屏时对话框固定中间
    regular() {
      const dialogEl = this.$refs.dialogs.$el;
      const contentEl = dialogEl.querySelector(".el-dialog");
      contentEl.style.top = 0;
      contentEl.style.left = 0;
    },
    // 改变弹框背景色和文字
    dialogBgColor() {
      this.$nextTick(() => {
        const dialogEl = this.$refs.dialogs.$el;
        const contentEl = dialogEl.querySelector(".el-dialog");
        contentEl.style.backgroundColor = this.bgColor;
        contentEl.style.color = this.bgColor == GREY ? BLACK : WHITE;
      });
    },
    onFullscreen() {
      this.fullscreen = !this.fullscreen;
    },
    // 根据远程用户改变布局
    getVideoClass() {
      if (this.remoteUsers.length <= 1) {
        return "remote-stream";
      } else if (this.remoteUsers.length >= 2 && this.remoteUsers.length < 4) {
        return "multi_three";
      } else if (this.remoteUsers.length >= 4 && this.remoteUsers.length < 6) {
        return "multi_five";
      } else if (this.remoteUsers.length >= 6) {
        return "multi_six";
      }
    },
    // 当远程用户只有一个,本地则重叠,否则跟远程用户九宫布局
    getLocalVideoClass() {
      if (!this.remoteUsers.length || this.remoteUsers.length === 1) {
        return "local-stream";
      } else {
        return "";
      }
    },
  },
  created() {
    this.joinRoom(this.paramsData);
  },
  mounted() {
    this.dialogBgColor();
  },
  beforeDestroy() {},
};
</script>

<style lang='less' scoped>
.container {
  // 穿透
  :deep(.el-dialog__wrapper) {
    pointer-events: none;
  }
  :deep(.el-dialog) {
    pointer-events: auto;
  }
  :deep(.el-dialog__header) {
    padding: 0;
  }
  :deep(.el-dialog__body) {
    height: 80%;
    padding: 0 10px;
  }
  :deep(.el-dialog__footer) {
    padding: 0;
  }
  #playContainer {
    width: 100%;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
    position: relative;
    box-sizing: border-box;
    .local-stream {
      width: 30% !important;
      height: 35% !important;
      // outline: 1px solid #000;
      position: absolute;
      bottom: 0px;
      right: 0px;
      z-index: 2;
    }
  }
  .remote {
    position: relative;
    .microphone {
      width: 20px;
      height: 20px;
      position: absolute;
      top: 5px;
      right: 5px;
      z-index: 999;
    }
    .camera {
      text-align: center;
      width: 100%;
      height: 100%;
      object-fit: contain;
    }
  }
  .remote-stream {
    width: 100%;
    height: 100%;
  }
  .multi_three {
    width: 50%;
    height: 50%;
  }
  .multi_five {
    width: 33.3%;
    height: 50%;
  }
  .multi_six {
    width: 33.3%;
    height: 33.3%;
  }
}
</style>

使用video-call组件

<template>
  <div>
  <el-button type="primary" @click="onCall">视频通话</el-button>
    <video-call :visible.sync="dialogVisible" :paramsData="callData"></video-call>
  </div>
</template>
<script>
import vidCall from "./vidCall.vue";
export default {
 components: { vidCall },
 data(){
   return{
     dialogVisible:false,
     callData:{
         appId:"声网项目appid",
         channel:"房间频道号",
         token:"加入房间令牌",
     }
  }
 },
 methods:{
  onCall(){
     this.dialogVisible=true
   },
 }
}
</script>

v-drag自定义指令拖拽

import Vue from 'vue'

Vue.directive('drag', {
  bind(el, binding) {
    const dialogHeaderEl = el.querySelector('.el-dialog__header');
    const dragDom = el.querySelector('.el-dialog');
    dialogHeaderEl.style.cssText += ';cursor: move;';
    dragDom.style.cssText += ';top: 0px;';

    // 获取原有属性,兼容不同浏览器
    const sty = window.document.currentStyle
      ? (dom, attr) => dom.currentStyle[attr]
      : (dom, attr) => getComputedStyle(dom, false)[attr];

    dialogHeaderEl.onmousedown = e => {
      // 鼠标按下,计算当前元素距离可视区的距离
      const disX = e.clientX - dialogHeaderEl.offsetLeft;
      const disY = e.clientY - dialogHeaderEl.offsetTop;
      const screenWidth = document.body.clientWidth; // body当前宽度
      const screenHeight = document.documentElement.clientHeight; // 可见区域高度
      const dragDomWidth = dragDom.offsetWidth; // 对话框宽度
      const dragDomHeight = dragDom.offsetHeight; // 对话框高度
      const minDragDomLeft = dragDom.offsetLeft;
      const maxDragDomLeft = screenWidth - dragDom.offsetLeft - dragDomWidth;
      const minDragDomTop = dragDom.offsetTop;
      const maxDragDomTop = screenHeight - dragDom.offsetTop - dragDomHeight;
      // 获取到的值带有单位"px",通过正则匹配替换掉单位
      let styL = sty(dragDom, 'left').replace(/\px/g, '');
      let styT = sty(dragDom, 'top').replace(/\px/g, '');

      // 如果初始值为百分比单位,则转换为像素单位
      if (styL.includes('%')) {
        styL = +document.body.clientWidth * (+styL.replace(/\%/g, '') / 100);
        styT = +document.body.clientHeight * (+styT.replace(/\%/g, '') / 100);
      } else {
        styL = +styL;
        styT = +styT;
      }

      document.onmousemove = function (e) {
        // 通过事件委托,计算移动的距离
        let left = e.clientX - disX;
        let top = e.clientY - disY;

        // 边界处理
        left = Math.max(-minDragDomLeft, Math.min(left, maxDragDomLeft));
        top = Math.max(-minDragDomTop, Math.min(top, maxDragDomTop));

        // 移动当前元素
        dragDom.style.cssText += `;left:${left + styL}px;top:${top + styT}px;`;
      };

      document.onmouseup = function () {
        document.onmousemove = null;
        document.onmouseup = null;
      };
      return false;
    };
  }
});

效果图

call.png

组件用到的图片

microphone1、microphone2、portrait

microphone1.png microphone2.png portrait.png

后言:去努力做困难且正确的事情