webrtc网页代码分析二

交互流程

上图是完整的处理流程。

获取URL参数

const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const deviceId = urlParams.get('deviceId');

这段代码先获取当前页面 URL 中的查询字符串部分(window.location.search),然后使用 URLSearchParams 对象解析查询字符串,尝试获取名为 deviceId 的参数值,后续很多操作会根据这个 deviceId 的有无来进行不同的逻辑处理。

页面加载时的初始化操作

window.onload = function () {
  if (deviceId == undefined) {
    document.getElementById('video').style.display = 'none';
    document.getElementById('getting-started').style.display = 'block';
  }
  const canvas = document.createElement('canvas')
  canvas.width = 640
  canvas.height = 480

  const ctx = canvas.getContext('2d')
  ctx.fillStyle = 'rgba(200, 200, 200, 100)'
  ctx.fillRect(0, 0, 640, 480)

  const img = new Image(640, 480)
  img.src = canvas.toDataURL()

  document.getElementById('imgStream').src = img.src;
}

window.onload是页面加载完成后的回调函数,上面使用的是匿名函数,即当页面所有内容加载完成后执行的逻辑。具体逻辑如下

这里判断如果deviceId未定义(也就是可能页面直接访问,没有带上对应的参数),则隐藏视频相关区域(id为video的div),显示初始引导内容区域(id 为 getting-started 的 div)。在函数内创建了一个 canvas 元素,绘制了一个半透明的矩形,将其转换为图片数据后设置给img id=\\\\"imgStream\\\\"元素作为初始显示内容。

MQTT操作

发起连接

var options = {
  keepalive: 600,
  username: 'xxx',
  password: 'xxxx',
};
const client = mqtt.connect('wss://xxx.xxx.com:8084/mqtt', options);

定义了 MQTT连接的相关选项(如保活时间、用户名、密码等),然后尝试连接到指定的MQTT服务器地址(wss://xxx.xxx.xxx:8084/mqtt),后续通过client对象来处理 MQTT 的各种消息收发以及相关操作。

事件处理

connect事件

client.on('connect', function () {
  console.log("connected")
  client.subscribe('webrtc/' + deviceId + '/jsonrpc-reply', function (err) {
    if (!err) {
    }
  });
  console.log('publish to webrtc/' + deviceId + '/jsonrpc');
  client.publish('webrtc/' + deviceId + '/jsonrpc', JSON.stringify({
    jsonrpc: '2.0',
    method: 'offer',
    id: offerId,
  }), { qos: 2 });
})

结构为client.on(\\\\'connect\\\\', function () {... }); client.on这里是mqtt对象的事件回调,这里将function(){...}作为回调函数注册进去,funciton实现是匿名函数的方式。当前收到connect事件时,就会执行function的内容。具体逻辑如下。
当成功连接到MQTT服务器后,先在控制台输出 connected 表示连接成功,然后订阅一个特定主题(webrtc/\\\\' + deviceId + \\\\'/jsonrpc-reply)用于接收回复消息,接着向另一个特定主题(webrtc/\\\\' + deviceId + \\\\'/jsonrpc)发布一条包含offer信息的 JSON 格式消息(设置了消息质量等级 qos 为 2),用于发起某种交互(可能与 WebRTC 相关的会话建立等操作)。

(1)订阅函数subscribe

client.subscribe(topic, options, callback);
  • topic: 需要订阅的主题,可以是一个字符串或者一个主题数组。主题是 MQTT 中用于分类消息的方式。主题的命名规则通常是层级的,使用斜杠(/)分隔每一层级,例如 \\\\'home/livingroom/temperature\\\\'。
  • options: 可选参数,配置订阅的一些选项,如 QoS(服务质量)级别。常见的选项包括:
    -- qos: 服务质量(Quality of Service)级别,取值范围是:
    -- 0 - 至多一次:消息最多发送一次,不做确认。
    -- 1 - 至少一次:消息至少发送一次,可能会重复。
    -- 2 - 只有一次:消息只能传递一次,不会丢失,也不会重复。
  • callback: 可选的回调函数,当订阅成功时会被调用。回调函数接受一个错误对象作为参数,如果订阅成功,err 为 null;如果订阅失败,则返回相应的错误信息。

(2)发布函数publish

client.publish(topic, message, options, callback);
  • topic: 需要发布消息的主题,可以是一个字符串,表示主题名称。

  • message: 需要发布的消息内容,可以是字符串、Buffer、对象等。一般情况下,消息会被自动转换为字符串或 Buffer(如果传入对象,会自动通过 JSON.stringify() 转换为字符串)。

  • options: 可选参数,配置发布消息的选项,常见的选项包括:
    -- qos: 服务质量(Quality of Service)级别(如前述),默认是 0。
    -- retain: 是否保留消息,若为 true,代理服务器会保存消息并在新订阅者连接时发送该消息。

  • callback: 可选的回调函数,发布消息成功后会被调用。回调函数接受一个错误对象作为参数,如果发布成功,err 为 null;如果发布失败,则返回相应的错误信息。

messange事件

MQTT消息接收处理(client.on(\\\\'message\\\\', function (topic, message) {... })),语法与上面类似,逻辑如下。

client.on('message', function (topic, message) {
  let msg = JSON.parse(message.toString());
  if (msg.id == offerId) {
    let sdp = msg.result;
    let offer = { type: 'offer', sdp: sdp };
    log(offer);
    pc.setRemoteDescription(offer);
    pc.createAnswer().then(d => pc.setLocalDescription(d)).catch(log);
  } else if (msg.id == answerId) {
    log('receive answer ok');
  }
})

当接收到MQTT消息时,先将消息内容解析为 JSON 对象,然后根据消息中的 id 判断消息类型,如果 id 与之前定义的 offerId 相等,说明是对之前 offer 的回复,会提取其中的 sdp 信息设置为远程描述(setRemoteDescription),并创建一个本地回答(createAnswer)然后设置本地描述(setLocalDescription);如果 id 等于 answerId,则只是在控制台输出表示接收到了回答消息。

WebRTC操作

创建对象

var pc = new RTCPeerConnection({
  iceServers: [
    {
      urls: "turn:rtpeer.allwinnertech.com:3478",
      username: "pctest",
      credential: "Aa123456",
    },
  ],
});

创建了一个 RTCPeerConnection 对象用于建立 WebRTC 连接,配置了 iceServers,这里指定了一个 TURN 服务器的相关信息(地址、用户名、密码),有助于在复杂网络环境(如存在 NAT 等情况)下建立稳定的点对点连接,确保音视频数据能顺利传输。

获取PC录音

navigator.mediaDevices.getUserMedia({ video: false, audio: true })
 .then(stream => {
    stream.getTracks().forEach(track => pc.addTrack(track, stream));
  }).catch(log);

通过 navigator.mediaDevices.getUserMedia 方法请求获取音频媒体流(这里明确只要音频,video: false, audio: true),如果获取成功,将音频轨道添加到 RTCPeerConnection 对象(pc)中,以便后续传输音频数据,如果出现错误则通过 log 函数(也就是在控制台输出错误信息)进行处理。

连接状态

pc.oniceconnectionstatechange = e => {
  log(pc.iceConnectionState);
  document.getElementById('status').innerHTML = pc.iceConnectionState;
  if (pc.iceConnectionState == 'connected') {
    // default to muted
    if (!isMuted)
      onMuted();
  }
}

当 WebRTC 连接的 ICE 连接状态发生变化时,先在控制台输出当前连接状态(通过 log 函数),然后更新页面上 id 为 status 的元素文本内容为当前连接状态,并且当连接状态变为 connected(已连接)时,如果当前不是静音状态(!isMuted),则调用 onMuted 函数将音频静音。

ICE候选者

    pc.onicecandidate = event => {
      if (event.candidate === null) {
        console.log(pc.localDescription.sdp)
        let json = {
          jsonrpc: \\'2.0\\',
          method: \\'answer\\',
          params: pc.localDescription.sdp,
          id: answerId,
        }
        console.log(json)
        client.publish(\\'webrtc/\\' + deviceId + \\'/jsonrpc\\', JSON.stringify(json))

        setInterval(() => {....}, 1000);

      }
    }

当有 ICE 候选者信息产生并且当候选者为 null 时(意味着 ICE 收集过程结束),执行一系列操作,包括打印 RTCPeerConnection 的本地描述中的 sdp 信息,向 MQTT 特定主题发布包含本地描述 sdp 信息的 JSON 消息,并且每隔 1 秒获取连接的统计信息(通过 pc.getStats 方法),整理后更新到页面中 id 为 stats-box 的元素内展示出来。

当收到对端的offer候选信息后,本地会进行与stun/turn服务交互,交互获取到ICE信息后,就会触发回调这个函数,将自己SDP信息组装成json通过mqtt publish发送给对端。

接收音视频

    pc.ontrack = function (event) {
      if (event.track.kind == \\'video\\') {
        var el = document.getElementById(\\'videoStream\\');
        var newStream = new MediaStream();
        newStream.addTrack(event.track);
        el.srcObject = newStream;
        el.autoplay = true
        el.controls = false
        el.muted = true
        document.getElementById(\\'imgStream\\').style.display = \\'none\\';
        document.getElementById(\\'videoStream\\').style.display = \\'block\\';
        // 当视频流添加成功后,获取视频分辨率和码率信息并更新显示
        el.addEventListener(\\'loadedmetadata\\', function () {
        updateVideoInfo(event.track);
    });
      } else if (event.track.kind == \\'audio\\') {
        var el = document.getElementById(\\'audioStream\\');
        var newStream = new MediaStream();
        newStream.addTrack(event.track);
        el.srcObject = newStream;
        el.controls = false
        el.muted = false
      }
    }

当通过 RTCPeerConnection 接收到远端传来的媒体轨道(视频或音频轨道)时,根据轨道类型进行不同的处理。对于视频轨道,获取页面中的视频元素(id 为 videoStream),创建新的 MediaStream 对象添加轨道后设置为视频元素的播放源,同时设置自动播放、隐藏控制条、静音等属性,并在视频元数据加载完成后(通过 loadedmetadata 事件)调用 updateVideoInfo 函数更新视频分辨率和码率信息显示;对于音频轨道,类似地获取音频元素(id 为 audioStream),添加轨道到新的 MediaStream 对象并设置为音频元素播放源,设置不显示控制条且不静音。

数据通道

    const datachannel = pc.createDataChannel(\\'pear\\')
    datachannel.onclose = () => console.log(\\'datachannel has closed\\');
    datachannel.onopen = () => {
      console.log(\\'datachannel has opened\\');
      console.log(\\'sending ping\\');
      setInterval(() => {
        console.log(\\'sending ping\\');
        datachannel.send(\\'ping\\');
      }, 1000);
    }

    datachannel.onmessage = e => {
      if (e.data.byteLength === undefined) {
        console.log(e.data);
      } else {
        // is binary data. mjpeg stream
        // console.log(e.data.byteLength);
        var arrayBufferView = new Uint8Array(e.data);
        var blob = new Blob([arrayBufferView], { type: \\"image/jpeg\\" });
        var urlCreator = window.URL || window.webkitURL;
        var imageUrl = urlCreator.createObjectURL(blob);

        var imageElement = document.getElementById(\\'imgStream\\');
        imageElement.src = imageUrl;
      }
    }

创建了一个名为pear的数据通道(const datachannel = pc.createDataChannel(\\'pear\\')),并分别对数据通道的打开、关闭、接收消息等事件绑定了相应的回调函数。例如在数据通道打开时(datachannel.onopen),会定期(每隔 1 秒)发送 ping 消息用于保持连接或检测连接状态等;在接收到消息时(datachannel.onmessage),根据消息数据类型判断,如果是文本消息直接在控制台输出,如果是二进制数据(判断为 MJPEG 流),则将其转换为图片的 URL 并设置给页面中的图片元素(id 为 imgStream)用于显示。

所以这里支持mjpeg的方式,但是是通过数据通道来实现的。