webrtc之一对多直播模式 + 弹幕实现

1,179 阅读8分钟

前几篇都是写的关于一对一实现音视频通话,然后在此基础上加了桌面共享、文件传输、关闭/开启麦克风/摄像头、使用外部视频替换以及使用虚拟背景这几个功能。那么这节就来看看如何实现一对多直播模式,看看和一对一音视频通话有啥区别,以及能不能直接将一对一音视频通话实现的功能直接移植过来。最后,再加一个弹幕功能,与主播实现实时互动效果。

注意:该节内容还是在一对一音视频的代码基础上修改的!

分析

考虑一下,一对多直播模式其实就是一个人和多个人建立连接,每当有人进入房间时,就自动和当前主播发起连接请求。本质上来说,一对多直播模式还是一对一音视频,只是一个人连接了多次而已。但是又有点不同,不同点在于主播看不到所有与他连接的观众,而观众可以看到主播。所以,当观众与主播连接时,观众不需要发送音视频给主播,只需要接收主播的音视频即可。而主播需要将他的音视频发送给每个连接的观众,不用接收观众的音视频。

这就是不同点,一对一是可收可发,一对多是观众只收不发。

那么如何才能实现上面这种效果呢?这就需要用到PeerConnection对象中的addTransceiver方法。官方地址在这里,想深入了解的可以前往。

这个addTransceiver方法在文档中的定义如下:

partial interface RTCPeerConnection {
  RTCRtpTransceiver addTransceiver((MediaStreamTrack or DOMString) trackOrKind,
                                   optional RTCRtpTransceiverInit init = {});
};

第一个参数:可以是一个媒体轨道,或者是"audio""video"中的一个。如果第一个参数是字符串,但不是"audio""video"中的一个,则会抛出错误。

第二个参数则有多个属性,定义如下:

dictionary RTCRtpTransceiverInit {
  RTCRtpTransceiverDirection direction = "sendrecv";
  sequence<MediaStream> streams = [];
  sequence<RTCRtpEncodingParameters> sendEncodings = [];
};

direction: 可选的值为"sendrecv"、"sendonly"、"recvonly"、"inactive"、"stopped"。默认为"sendrecv"。

streams: 当远程 PeerConnection 的跟踪事件对应于添加的 RTCRtpReceiver 触发时,这些是将放入事件中的流。(尚未真正理解,但目前用不上这个)。

sequence: 包含用于发送媒体 RTP 编码的参数的序列。

这里重点关注direction的值,"sendrecv"为 可接收可发送,"sendonly"为 只发送,"recvonly"为 只接收,"inactive"为 非活动,即两边都关闭,"stopped"为 停止,两边都停止任何活动

对于主播来说,可接收可发送。但对于观众来说,则只能是只接收了。

还有一个点要注意,看文档中的这段描述:

Create a new RTCRtpTransceiver and add it to the set of transceivers.

Adding a transceiver will cause future calls to createOffer to add a media description for the corresponding transceiver, as defined in [RFC8829] (section 5.2.2.).

大致意思就是新建一个收发器,然后这个收发器将会影响到创建的offer。但如果在已经建立连接的过程中执行了这个方法,不知道会不会导致媒体重新协商,有待尝试。还有,这种方式能不能直接用于关闭/开始麦克风/摄像头呢?理论上来说也是可行的,有待尝试。机智如我,要是可行的话,那么这种方式将是最简单的控制麦克风/摄像头的方法了,有空试一试。

上面还提到一个弹幕功能,实现这个功能用到了danmaku这个安装包,具体文档地址可前往这里,就不再详细介绍了。

实操

api已经知道了,再来捋一遍过程。首先是区分主播和观众,将房间内带有pub标识的用户当成主播,而其他的则为观众。这样,当主播进入房间后,观众自动连接主播,后进入的观众也是如此。然后,主播的话,则可以接收和发送音视频;而观众则设置为只接收音视频。

根据上面这个过程,只需要在一对一音视频的基础上做一些简单的改动即可,代码如下:

  1. 新加上pub属性,标识主播
onMounted(() => {
  // 带有pub表示为发布者,即主播
  // 观众是不带有pub的
  const { nickName, roomId, userId, pub } = props;
  Object.assign(userInfo, { nickName, roomId, userId, pub });
  init(nickName, roomId, userId, pub);

  // 这个是虚拟背景用到
  nextTick(() => {
    videoDom = document.getElementById("videoDom");
    if (pub) {
      canvasDom = document.getElementById("canvasDom");
      canvasCtx = canvasDom.getContext("2d");
    }
  });
});
  1. 当拿到房间内所有用户时,找出主播并连接
linkSocket.value.on("roomUserList", async (data) => {
roomUserList.value = data;

if (roomUserList.value.length) {
  await initMeetingRoomPc();
  initDanmuContainer();
}
});

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;
  }

  // 如果是自己 且 自己是主播 则直接返回
  if (pub.userId === localUserId) {
    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);
  }

  // 设置收发器  设置为只接收
  pc.addTransceiver("audio", { direction: "recvonly" });
  pc.addTransceiver("video", { direction: "recvonly" });

  onPcEvent(pc, localUserId, publisher.value.userId);

  // 创建数据通道
  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);
};

这里不同于一对一,而是先找到主播,进行判断。没有主播则不进行连接,如果主播是自己也是不用连接。找到后再设置下收发器,再进行连接。后面连接的过程和一对一一音视频是一样的,就不再说了。

到这里其实就完成了一对多直播模式的音视频通话了,非常简单。和一对一的区别只有两点,一是每个用户进入之后,主动找到主播并进行连接。二是观众的收发器是只进行接收的。除此之外,其他没有区别了。所以,再来回答开头那个问题,之前在一对一音视频通话基础上拓展的功能也是可以移植过来的,没啥问题。

弹幕实现

新增弹幕功能是为了能增加互动效果,没有弹幕光看直播,感觉怪怪的。

弹幕的api如何使用就不在说了,我看文档里的使用写的很清楚了,地址是这里。重点是实现的思路,有以下两种方式:

  1. 使用PeerConnection.channel:

当观众给主播发送消息后,主播收到消息,再遍历所有建立连接的channel单独发送消息。推荐使用这种方式,即使当你服务器挂了,观众还是可以正常发送弹幕。

  1. 使用socket.io

观众利用socket的广播来实现,发送消息后触发msg事件,然后后端监听该事件,执行io.to(roomKey).emit('msg',msg);即可达到房间内全员接收的效果。这种方式实现很简单,一行代码即可搞定。但是需要服务器的配合,一旦服务器出现问题就没法实现了。

关于第二种方式就不写了,很简单的。这里看看第一种实现,代码如下:

这是html部分,加了个弹幕的按钮

<div class="content" ref="content">
  <div class="videoWrap" ref="videoWrap">
    <div v-if="userInfo.pub === 'pub'">
      <video autoplay controls muted class="demo1" id="videoDom"></video>
      <canvas
        class="canvas"
        id="canvasDom"
        width="800px"
        height="720px"
      ></canvas>
    </div>
    <video
      ref="remoteVideoRef"
      autoplay
      controls
      muted
      class="demo1"
      v-else
    ></video>
  </div>
  <div class="barrageWrap" v-if="userInfo.pub !== 'pub'">
    <el-input v-model="barrage" placeholder="发送弹幕" />
    <el-button type="primary" @click="sendMsgToPub">发送弹幕</el-button>
  </div>
</div>

关于js部分

第一步是初始化弹幕容器,这里加了一条自动弹幕,当初始化之后会自动出现

// 初始化弹幕容器
const initDanmuContainer = () => {
  danmaku.value = new Danmaku({
    container: videoWrap.value,
    speed: 30,
  });

  // 首条弹幕
  danmaku.value.emit({
    text: "欢迎进入直播间,发个弹幕试试",
    style: {
      color: "red",
      fontSize: "16px",
      marginTop: "20px",
    },
  });
};

第二步是发送弹幕,也就是观众给主播发,主播给所有连接的用户发

  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 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 = "";
};

// 广播消息
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);
        };
      }
    });
  }
};

// 直播弹幕留言
const danmuForRoller = (msg) => {
  if (danmaku.value) {
    danmaku.value.emit({
      text: msg,
      style: { color: "red", fontSize: "20px" },
    });
  }
};

当观众执行sendMsgToPub方法后,通过channel给主播发送消息。主播则会在e.channel.onmessage方法中监听并接收到消息。然后主播触发onAllMessage事件,在这个事件中先显示弹幕,然后判断是否为主播,如果是主播则再进行消息广播。然后观众再通过e.channel.onmessage方法中监听并接收到消息;不是主播,则只需要显示弹幕就行。

如果是主播自己发送的弹幕,则可以直接调用onAllMessage方法了。

第二种方式代码量确实比第一种多一些,逻辑也复杂一些。但它有一个最大的好处就是连接不断,就能一直发送弹幕,不受服务器影响。

小节

本小节介绍了如何实现一对多直播模式,只需在一对一的基础上进行小小的改动,一是将手动连接改为进入房间后自动连接;二是区分观众和主播,将观众的收发器改为只接收。这样就轻松的实现了一对多直播模式了。所以,一对一的所有功能也都可以在一对多上实现。然后,新增了弹幕功能,增加了趣味性和互动性。

下一小节再来看看如何实现多人多房间,以及有哪些方式可以实现,冲!!!