WebRTC实现一对多直播模式和弹幕发送功能
在上一篇webrtc实现一对一音视频和类IM即时通讯中已经实现了一对一视频,大体上熟悉了WebRTC的基本用法以及它的会话流程。WebRTC的本质就是 P2P,即点对点的即时通讯,现在学习一下一对多直播模式。
粉丝福利, 免费领取C++音视频学习资料包+学习路线大纲、技术视频/代码,内容包括(音视频开发,面试题,FFmpeg ,webRTC ,rtmp ,hls ,rtsp ,ffplay ,编解码,推拉流,srs)↓↓↓↓↓↓见下面↓↓文章底部点击免费领取↓↓
基础构思
在开始之前,先熟悉下这个最简单的讲课场景,首先看下图模拟场景,T 作为老师,需要将自己的画面实时地发送给下面的三个学生,但是学生却不需要将自己的画面同步给老师,而仅仅是在需要反馈的时候给予老师反馈即可。这个场景就是一对多直播模式。
场景很清晰了,现在来构思和实战了。
WebRTC实现 P2P 视频通话以及 类IM即时通讯 都没问题了,就是说 T 和 S-1 、S-2、S-3 单独完成视频通话和普通消息发送都没问题,那怎么实现一次性同时和三个学生建立通话呢?
很简单,老师 T 和他们三个单独建立视频通话后,将关联关系都保存在本地不就可以?
前面反复提到过WebRTC的核心就是PeerConnection对象,任何建立视频通话的双方都离不开这个对象,因为这里面包含连接双方的核心协商数据。所以只要 T 和三个学生建立关联关系时,都维护一份独立的PeerConnection对象即可。
如上图,老师端保存三份独立的PeerConnection对象,而学生端只需要保存自己和老师的关联信息,即一份核心对象。用代码描述如下:
老师端
// T:9999 S-1:1 S-2:2 S-3:3 分别代表上面流程图中的师生
const RtcPcMaps = new Map()
const TS01= 9999-1
const TS02= 9999-2
const TS03= 9999-3
RtcPcMaps.set(TS01, new PeerConnection()) //维护T-S-1关系
RtcPcMaps.set(TS01, new PeerConnection()) //维护T-S-2关系
RtcPcMaps.set(TS01, new PeerConnection()) //维护T-S-3关系
学生端
//S-1:10001
const RtcPcMaps = new Map()
const S01T= 10001-9999
RtcPcMaps.set(S01T, new PeerConnection()) //学生维护和老师的关联
//S-2:10002
const RtcPcMaps = new Map()
const S02T= 10002-9999
RtcPcMaps.set(S02T, new PeerConnection()) //学生维护和老师的关联
//S-3:10003
const RtcPcMaps = new Map()
const S03T= 10003-9999
RtcPcMaps.set(S03T, new PeerConnection()) //学生维护和老师的关联
可以很清晰地看到,在学生端只需要维护一份和老师的关系即可,在建立关联之后,将老师的直播流拉取,然后在本地展示。
实战
有了上面的大体构思和基础伪代码,接下来思考下从老师直播到学生观看直播,这整个过程架构上的整体设计。
首先,老师讲课学生听课,实际上就是在一个房间,老师在黑板书写,而学生看黑板。也可以说老师在房间内直播,学生在下面看直播。
所以,实际上我们整体的架构设计,就是围绕一个“房间”展开,在房间中学生和老师互动。
但是,考虑一下,同一个房间中有老师和学生,那么如何区分老师和学生的身份呢?这就是我们架构设计上的第一个重点,那就是加入这个房间后,所有用户的身份标识。有了标识,后面加入的学生才知道和有老师身份的用户进行WebRTC关联。
onMounted(() => {
// 带有pub表示为发布者,即老师
// 学生是不带有pub的
// 一个房间内只能有一个发布者
const { nickName, roomId, userId, pub } = props;
init(nickName, roomId, userId, pub);
});
在客户端进入这个房间(即页面)时,会带上用户的身份信息和pub标识,表示该用户是学生还是老师。然后会将这些信息保存到服务端:
老师端作为发布者,在加入房间后就需要发布自己的直播流,此时还没有任何学生和他建立连接。
const initMeetingRoomPc = async () => {
if (userInfo.pub) {
localStream.value = await getLocalUserMedia({ video: true, audio: true });
//将本地直播流挂到video标签,在自己的页面显示
setDomVideoStream("video", localStream.value);
}
...
}
学生端进入房间后,首先获取用户列表,获取到用户列表后找到老师,和老师建立WebRTC连接。
const initMeetingRoomPc = async () => {
if (userInfo.pub) {
localStream.value = await getLocalUserMedia({ video: true, audio: true });
//将本地直播流挂到video标签,在自己的页面显示
setDomVideoStream("video", localStream.value);
}
const localUserId = userInfo.userId;
// 找到当前房间的视频流发布者,即主播
const pub = roomUserList.value.find((item) => item.pub === "pub");
if (!pub) {
return;
}
publisher.value = pub;
// 和发布者建立rtc连接 不发送自己的视频流
const pcKey = localUserId + "-" + publisher.value.userId;
let pc = RtcPcMaps.get(pcKey);
if (!pc) {
pc = new PeerConnection();
RtcPcMaps.set(pcKey, pc);
}
// sendrecv 表示发送和接收都开启 sendonly 表示只发送不接收 recvonly 表示只接收不发送
pc.addTransceiver("audio", { direction: "recvonly" });
pc.addTransceiver("video", { direction: "recvonly" });
onPcEvent(pc, localUserId, publisher.value);
// 创建数据通道
await createDataChannels(pc, localUserId, publisher.value.userId);
// 创建offer
const offer = await pc.createOffer();
// 设置offer为本地描述
await pc.setLocalDescription(offer);
// 发送offer给远端
const params = {
userId: localUserId,
targetUserId: publisher.value.userId,
offer,
};
linkSocket.value.emit("offer", params);
};
老师端在收到学生的关联意向之后,就是正常的一对一视频了,关联的思路还是之前那张图,忘记了可以回看一下webrtc的会话流和信令服务器搭建这篇,多看几遍,对照代码就能记住了,
总结一下,一对多拆开来就是多个一对一,重点就是在进入房间后,要在所有用户中筛选出发布者并与之建立联系,之后的流程就是一对一了。
弹幕实现
这里使用了danmaku这个npm包,在本地直播流挂载到video标签后,再进行初始化弹幕容器,代码如下:
linkSocket.value.on("connect", () => {
console.log("链接成功");
const timer = setTimeout(async () => {
if (roomUserList.value.length) {
await initMeetingRoomPc();
initDanmuContainer();
}
clearTimeout(timer);
}, 2000);
});
// 初始化弹幕容器
const initDanmuContainer = () => {
if (userInfo.pub === "pub") {
danmaku.value = new Danmaku({
container: videoWrap.value,
speed: 30,
});
} else {
danmaku.value = new Danmaku({
container: videoWrap.value,
speed: 30,
});
}
// 首条弹幕
danmaku.value.emit({
text: "欢迎进入直播间,发个弹幕试试",
style: {
color: "red",
fontSize: "16px",
marginTop: "20px",
},
});
};
这里还可以分别实现老师端和学生端的弹幕容器,首次初始化后会发送一条弹幕。
如何实现学生端发送弹幕,其他所有学生都能看见呢?考虑一下,在这个房间内,谁具有所有学生的关联关系?当然是老师,老师端关联了所有其他学生,他可以广播信息给所有其他学生。那么就可以这样考虑实现弹幕功能: 学生先把弹幕发送给老师,再由老师进行广播发送给所有学生。
第一步,先实现学生把弹幕发送给老师,代码如下:
// 指定数据通道发送数据
const clientDataChannelMsg = (userId, targetUserId, msg) => {
const channel = dataChannelMap.get(userId + "-" + targetUserId);
if (channel) {
channel.send(msg);
}
};
// 发送弹幕
const sendMsgToPub = () => {
// 给发布者发送消息 发布者收到再广播给其他客户端
clientDataChannelMsg(userInfo.userId, publisher.value.userId, barrage.value);
barrage.value = "";
};
根据学生id和老师id找出两者之间的消息通道,再通过消息通道把消息发送出去给老师。
第二步,老师收到消息后广播信息给所有学生,代码如下:
pc.ondatachannel = (e) => {
console.log("收到数据通道", e);
e.channel.onopen = () => {
console.log("通道打开");
};
e.channel.onclose = () => {
console.log("通道关闭");
};
e.channel.onmessage = (data) => {
console.log("收到消息", data.data);
// 弹幕发送到屏幕上
onAllMessage(data.data);
};
// 广播消息
const onAllMessage = (msg) => {
danmuForRoller(msg);
if (userInfo.pub === "pub") {
// 如果是发布者 则遍历所有数据通道给每个客户端发送消息
dataChannelMap.forEach((value, key) => {
if (value.readyState === "open") {
value.send(msg);
} else {
// 处理通道未打开的情况,可以等待通道打开后再发送数据
value.onopen = () => {
value.send(msg);
};
}
});
}
};
实现起来还是挺简单的,捋清楚关系后就容易实现了。
项目操作
项目地址
# 老师进入
https://xx/#/demo4?userId=123223444&roomId=小程直播间&nickName=JAVA&pub=pub
# 学生进入
https://xx/#/demo4?userId=12&roomId=小程直播间&nickName=Jack
# 学生进入
https://xx/#/demo4?userId=123&roomId=小程直播间&nickName=Rose
粉丝福利, 免费领取C++音视频学习资料包+学习路线大纲、技术视频/代码,内容包括(音视频开发,面试题,FFmpeg ,webRTC ,rtmp ,hls ,rtsp ,ffplay ,编解码,推拉流,srs)↓↓↓↓↓↓见下面↓↓文章底部点击免费领取↓↓