前言
- 本文基于vue、Element-ui、Agora Web SDK4.15.1版本实现音视频通话。
- Agora Web SDK 是通过 HTML 网页加载的 JavaScript 库。Agora Web SDK 库在网页浏览器中调用 API 建立连接,控制音视频通话和直播服务。
Agora
- Agora官网 创建账号
- 项目管理-创建项目-填写名称、场景选音视频通话
- 创建完后-配置-输入房间名称创建临时token
- 项目主用到是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;
};
}
});
效果图
组件用到的图片
microphone1、microphone2、portrait
后言:去努力做困难且正确的事情