上一小节写了如何实现一个简单的信令服务器,麻雀虽小五脏俱全,也不是不能用哈。回想一下,媒体协商、SDP和ICE交换、信令服务器都有了(忘了请往回看看),现在也该动手实现一个完整的、简单的webrtc音视频通话了。
这里有几个点提前声明:
- 使用的是vue3框架,用的是
socket.io-client库 - 实现的是内网环境下的音视频通话,即不考虑网络环境问题,默认是同一环境下(后续会介绍使用STUN和TURN来实现不同环境下连接)
- 仅实现 p2p 通话,多人模式也是后续介绍(其实也就是多个 p2p互相连接罢了)
- 需要用到上一小节实现的信令服务器
现在来动手实现了。先看看html部分,这个简单,大致看看:
<div v-for="item in roomUserList" :key="item.userId" class="userList">
{{ "用户" + item.nickName }}
<el-tag size="small" v-if="userInfo.userId === item.userId"
>本人</el-tag
>
<template v-if="userInfo.userId !== item.userId">
<el-button size="small" type="primary" @click="call(item)">
{{ callSate[item.userId] || "通话" }}
</el-button>
<el-button
v-if="callSate[item.userId] === '通话中'"
size="small"
type="primary"
@click="handleHangUp(item)"
>
挂断
</el-button>
</template>
</div>
<video ref="videoRef" autoplay muted></video>
<video ref="remoteVideoRef" autoplay muted></video>
代码很简单,前面主要就是遍历roomUserList展示出当前在线人员,然后可以与在线且没有正在通话的人进行音视频通话。后面两个video标签就是用来展示自己和对方的视频。样式的话,自由发挥了。
注意,自己不能和自己通话,要排除掉。正在通话中的也要排除掉。
逻辑实现
再来看看重点部分,具体的逻辑实现如下:
- 用户进入到页面后,会自动生成一个用户id,其他的如用户名等信息可以是自己填写,也可以自动生成。注意用户id必须确保唯一性,可以由后端来给出。当然,这节就简单实现,前端用
nanoid库来生成。 - 进入页面后,自动连接后端
socket,然后监听一系列事件,比如roomUserList事件来获取在线人员列表,join和leave事件,offer和ice事件等。 - 连接后端
socket成功后,会后端会触发roomUserList事件,并返回在线人员列表,获取到后就将他们显示出来。 - 随机选择一个在线人员,点击通话按钮。注意这时并没有就直接发起媒体协商,而是要询问一下对方是不是愿意接受你的通话,如果接受再进行媒体协商,不能接受只能放弃了,连接了也没有用。这里就可以再添加两个监听事件,在主动发起方触发
preCall事件,告知对方想要进行通话。接受方监听preCall事件,做出选择后触发backCall事件告知发起方是否接受,主动发再监听backCall事件来判断对方是否接受了,再考虑是否要发起媒体协商。 - 对方接受之后,发起方和接受方都开始初始化本地摄像头,将本地视频显示在
videoRef中。同时,发起方会初始化PeerConnection,并且触发offer事件,将offer发送给接受方。接受方也初始化一个PeerConnection,监听offer事件,将接收到的offer保存到本地,然后生成一个answer,然后再触发answer事件。主动方监听answer事件,接收到answer并保存到本地,然后双方开始ice收集过程,都监听ice事件,直到完成ice收集,建立起连接进行音视频通话。 - 接下来就是要捕获错误,在音视频通话过程中出现了异常要及时捕获到。这里就可以监听
PeerConnection的onconnectionstatechange事件,在这个事件中判断peer的连接状态变化,并给出相应的提示。还有一个peerError对象,不过这个没有在文档中看到demo示例,所以就不展开了,想深入的可以看这里。 - 再然后就是终止连接了,两方的任意一方都可以随意终止连接,只需要调用
PeerConnection的close事件即可。然后触发hangUp事件,通知对方已经终止了连接。接受方监听hangUp,然后也调用close事件,并且进行一系列销毁事件。到这里就算是完成了一整个音视频通话的流程了。
代码实现
下面看具体代码,比较长:
const PeerConnection =
window.RTCPeerConnection ||
window.mozRTCPeerConnection ||
window.webkitRTCPeerConnection;
// 处理报错
const handleError = (error) => {
console.error(
"navigator.MediaDevices.getUserMedia error: ",
error.message,
error.name
);
};
// 设置本地视频源
const setDomVideoStream = async (newStream) => {
const dom = videoRef.value;
const stream = dom.srcObject;
// 如果媒体流已经存在 则停止
if (stream) {
stream.getAudioTracks().forEach((e) => {
stream.removeTrack(e);
});
stream.getVideoTracks().forEach((e) => {
stream.removeTrack(e);
});
}
dom.srcObject = newStream;
};
// 设置远程视频和音频
const setRemoteDomVideo = (track) => {
if (track.kind === "audio") {
const audioDom = remoteAudioRef.value;
const stream = audioDom.srcObject;
let newStream;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
newStream = new MediaStream([track]);
audioDom.srcObject = newStream;
} else {
// 不存在,则创建并添加
newStream = new MediaStream();
newStream.addTrack(track);
audioDom.srcObject = newStream;
}
remoteAudioSteam.value = newStream;
} else if (track.kind === "video") {
const videoDom = remoteVideoRef.value;
const stream = videoDom.srcObject;
let newStream;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
newStream = new MediaStream([track]);
videoDom.srcObject = newStream;
} else {
// 不存在,则创建并添加
newStream = new MediaStream();
newStream.addTrack(track);
videoDom.srcObject = newStream;
}
remoteVideoStream.value = newStream;
}
};
// 获取设备的媒体流
const getLocalUserMedia = async (constraints) => {
return await navigator.mediaDevices
.getUserMedia(constraints)
.catch(handleError);
};
// 初始化
const init = async (userId, roomId, nickName) => {
Object.assign(userInfo, {
userId,
nickName,
roomId,
});
// 初始化本地媒体信息
localStream.value = await getLocalUserMedia({
video: true,
audio: true,
});
// 本地dom渲染 必须渲染完成后才能设置
await setDomVideoStream(localStream.value);
// 初始化socket
linkSocket.value = io(serverUrl, {
reconnectionDelayMax: 10000,
transports: ["websocket"],
query: {
userId,
roomId,
nickName,
},
});
linkSocket.value.on("connect", () => {
console.log("链接成功");
});
linkSocket.value.on("error", (e) => {
console.log("错误", e);
});
linkSocket.value.on("roomUserList", (data) => {
roomUserList.value = data;
console.log("房间用户列表", roomUserList.value);
});
linkSocket.value.on("msg", async (e) => {
const handler = eventHandlers[e.type];
if (handler) {
await handler(e);
}
});
};
// 各种Msg事件
const eventHandlers = {
join: async () => {
const params = {
roomId: props.roomId,
};
linkSocket.value.emit("roomUserList", params);
},
leave: async () => {
const params = {
roomId: props.roomId,
};
linkSocket.value.emit("roomUserList", params);
},
offer: async (e) => {
const { targetUserId, userId, offer } = e?.data;
await onRemoteOffer(userId, offer, targetUserId);
},
answer: async (e) => {
const { userId, answer, targetUserId } = e?.data;
await onRemoteAnswer(userId, answer, targetUserId);
},
candidate: async (e) => {
const { userId, candidate } = e?.data;
await onCandiDate(userId, candidate);
},
sendFile: (e) => {
const { userId, content, targetUserId, fileType } = e?.data;
onSendFile(userId, content, targetUserId, fileType);
},
receiveFile: (e) => {
const { userId, content, targetUserId, type } = e?.data;
onReceiveFile(userId, content, targetUserId, type);
},
preCall: (e) => {
const { userId, targetUserId, type } = e?.data;
onPreCall(userId, targetUserId, type);
},
backCall: async (e) => {
const { userId, targetUserId, type } = e?.data;
await onBackCall(userId, targetUserId, type);
},
hangUp: (e) => {
const { userId, targetUserId } = e?.data;
onHangUp(userId, targetUserId);
},
callInfo: (e) => {
onSetCallInfo(e?.data);
},
hanUpCallInfo: (e) => {
onHangUpCallInfo(e?.data);
},
micStatusChange: (e) => {
const { userId, targetUserId, type } = e?.data;
onChangeMic(userId, targetUserId, type);
},
webcamStatusChange: (e) => {
const { userId, targetUserId, type } = e?.data;
onChangeWebcam(userId, targetUserId, type);
},
};
// 发出 预呼叫
const call = async (item) => {
// 本人正在通话中 则提示
if (
callInfoList.includes(item.userId) ||
callInfoList.includes(props.userId)
) {
ElMessage({
type: "error",
message: `正在通话中,不能重复呼叫`,
});
return;
}
linkSocket.value.emit("preCall", {
userId: props.userId,
targetUserId: item.userId,
content: "你小子,我想给你打个视频电话",
});
remoteUserId.value = item.userId;
};
// 接收到 预呼叫
const onPreCall = (fromUserId, targetUserId) => {
if (targetUserId !== props.userId) return;
ElMessageBox.confirm(`是否接受来自${fromUserId}的视频呼叫`, "info", {
confirmButtonText: "接受",
cancelButtonText: "拒绝",
type: "info",
center: true,
})
.then(() => {
callSate[fromUserId] = "连接中";
callerId = fromUserId;
calleeId = targetUserId;
linkSocket.value.emit("backCall", {
userId: targetUserId,
targetUserId: fromUserId,
content: "我接受了你的视频呼叫",
type: "OK",
});
})
.catch(() => {
callerId = "";
calleeId = "";
callSate[fromUserId] = "通话";
linkSocket.value.emit("backCall", {
userId: targetUserId,
targetUserId: fromUserId,
content: "那个吊毛拒绝了你的视频呼叫",
type: "NO",
});
});
};
// 返回呼叫
const onBackCall = async (fromUserId, targetUserId, type) => {
if (targetUserId !== props.userId) return; // 不是自己
if (type === "OK") {
// 初始话呼叫者信息
callSate[fromUserId] = "连接中";
callerId = targetUserId;
calleeId = fromUserId;
await initCallerInfo(callerId, calleeId);
} else {
callerId = "";
calleeId = "";
callSate[fromUserId] = "已拒绝";
ElMessage.success(`拒绝了来自${fromUserId}的视频呼叫`);
}
};
// 初始化呼叫者信息
const initCallerInfo = async (userId, targetUserId) => {
const pcKey = userId + "-" + targetUserId;
let pc = RtcPcMaps.get(pcKey);
if (!pc) {
pc = new PeerConnection();
RtcPcMaps.set(pcKey, pc);
}
localRtcPc.value = pc;
// 添加轨道
for (const track of localStream.value.getTracks()) {
const sender = pc.getSenders().find((s) => s.track === track);
if (sender) {
// 如果发送器已经存在,则更新发送器的轨道
sender.replaceTrack(track);
} else {
// 否则,添加新的轨道
pc.addTrack(track, localStream.value);
}
}
// 触发监听
onPcEvent(pc, userId, targetUserId);
// 创建offer
const offer = await pc.createOffer({ iceRestart: true });
// 设置offer为本地描述
await pc.setLocalDescription(offer);
// 发送offer给被呼叫者
const params = {
userId,
targetUserId,
offer,
};
linkSocket.value.emit("offer", params);
};
// 挂断信息
const handleHangUp = (item) => {
const pcKey = callerId + "-" + calleeId;
const pc = RtcPcMaps.get(pcKey);
pc.close();
RtcPcMaps.delete(pcKey);
remoteVideoRef.value.srcObject = null;
remoteVideo.value = false;
localRtcPc.value = null;
_hark.stop();
ElMessage.success(`挂断了与${item.nickName}的视频呼叫`);
linkSocket.value.emit("hangUp", {
userId: props.userId,
targetUserId: item.userId,
callerId,
content: "我挂了啊",
type: "NO",
});
};
// 处理挂断
const onHangUp = (fromUserId, targetUserId) => {
if (targetUserId !== props.userId) return; // 不是自己
const pcKey = callerId + "-" + calleeId;
const pc = RtcPcMaps.get(pcKey);
pc.close();
RtcPcMaps.delete(pcKey);
remoteVideo.value = false;
localRtcPc.value = null;
_hark.stop();
remoteVideoRef.value.srcObject = null;
};
// 处理远端offer
const onRemoteOffer = async (fromUserId, offer, targetUserId) => {
const localUserId = props.userId;
remoteUserId.value = fromUserId;
// targetUserId 为被呼叫端的userId 不等于 userId 说明呼叫的不是自己 直接返回
if (targetUserId !== localUserId) return;
const pcKey = fromUserId + "-" + localUserId;
const pc = new PeerConnection();
RtcPcMaps.set(pcKey, pc);
onPcEvent(pc, localUserId, fromUserId);
localRtcPc.value = pc;
for (const track of localStream.value.getTracks()) {
const sender = pc.getSenders().find((s) => s.track === track);
if (sender) {
// 如果发送器已经存在,则更新发送器的轨道
sender.replaceTrack(track);
} else {
// 否则,添加新的轨道
pc.addTrack(track, localStream.value);
}
}
await pc.setRemoteDescription(new RTCSessionDescription(offer)); // 保存远端发送给自己的信令
const answer = await pc.createAnswer(); // 创建应答
await pc.setLocalDescription(new RTCSessionDescription(answer)); // 保存应答
const params = {
userId: localUserId,
targetUserId: fromUserId,
answer,
};
linkSocket.value.emit("answer", params);
};
// 处理远端answer
const onRemoteAnswer = async (fromUserId, answer) => {
const localUserId = props.userId;
const pcKey = localUserId + "-" + fromUserId;
const pc = RtcPcMaps.get(pcKey);
await pc.setRemoteDescription(new RTCSessionDescription(answer));
};
// 处理 ice 候选信息
const onCandiDate = async (fromUserId, candidate) => {
const localUserId = props.userId;
const pcKey = localUserId + "-" + fromUserId;
let pc = RtcPcMaps.get(pcKey);
if (pc) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
};
const onPcEvent = (pc, userId, targetUserId) => {
// 监听远程媒体轨道即远端音视频信息
pc.ontrack = (e) => {
setRemoteDomVideo(e.track);
console.log(e, "e--------------");
};
// 需要协商新的连接时会被触发 例如在添加新的数据流或者移除数据流时
pc.onnegotiationneeded = (e) => {
console.log("需要协商新的连接", e);
};
pc.oniceconnectionstatechange = (e) => {
if (pc.iceConnectionState === "checking") {
console.log("ice正在收集中...");
}
if (
pc.iceConnectionState === "connected" ||
pc.iceConnectionState === "completed"
) {
console.log("ice收集完成");
}
};
pc.onconnectionstatechange = (e) => {
if (pc.connectionState === "connected") {
console.log("连接成功");
callSate[targetUserId] = "通话中";
remoteVideo.value = true;
// 发送呼叫信息 记录下正在通话的双方ID
linkSocket.value.emit("callInfo", {
roomId: props.roomId,
calleeId,
callerId,
});
}
if (pc.connectionState === "disconnected") {
console.log("连接断开");
callSate[targetUserId] = "挂断";
}
if (pc.connectionState === "failed") {
console.log("连接失败");
callSate[targetUserId] = "通话失败";
remoteVideo.value = false;
}
if (pc.connectionState === "connecting") {
console.log("连接中");
callSate[targetUserId] = "连接中";
}
if (pc.connectionState === "closed") {
console.log("连接关闭");
}
};
// 当 ICE 框架收集到新的 ICE 候选时触发,用于处理 ICE 候选的事件
pc.onicecandidate = (e) => {
if (e.candidate) {
const params = {
userId,
targetUserId,
candidate: e.candidate,
};
linkSocket.value.emit("candidate", params);
} else {
console.log("在此次协商中,没有收集到新的 ICE 候选");
}
};
};
onMounted(() => {
const userId = nanoid();
const { nickName, roomId } = props;
if (nickName && roomId && userId) {
init(userId, roomId, nickName);
}
});
总的来说,就是要思路清晰,知道每个步骤要干什么,然后处理相应的事件就好了。一方做了什么,另一方需要相应的做出回复。就这么一来一回,看着是有点啰里啰嗦,感觉很复杂,其实非常简单。一定非常熟悉媒体协商的具体过程,这个是重点。
这里补充一点PeerConnection的事件,更详细的信息去这里:
小节
本小节介绍了如何实现一个简单的音视频通话,分析了具体的实现逻辑,然后给出了具体的代码示例供参考。重点在于熟悉媒体协商的步骤,这个是必不可少的。其次就是要学会角色扮演,需要同时站在发起方和接受方的角度去思考,如何去进行连接,有哪些可能的步骤,可能会有哪些问题。
下一小节将会在此基础上拓展功能,比如发送消息、桌面共享或者发送文件等,冲!!