• WebRTC:一个视频聊天的简单例子


    相关API简介

    在前面的章节中,已经对WebRTC相关的重要知识点进行了介绍,包括涉及的网络协议、会话描述协议、如何进行网络穿透等,剩下的就是WebRTC的API了。

    WebRTC通信相关的API非常多,主要完成了如下功能:

    1. 信令交换
    2. 通信候选地址交换
    3. 音视频采集
    4. 音视频发送、接收

    相关API太多,为避免篇幅过长,文中部分采用了伪代码进行讲解。详细代码参考文章末尾,也可以在笔者的Github上找到,有问题欢迎留言交流。

    信令交换

    信令交换是WebRTC通信中的关键环节,交换的信息包括编解码器、网络协议、候选地址等。对于如何进行信令交换,WebRTC并没有明确说明,而是交给应用自己来决定,比如可以采用WebSocket。

    发送方伪代码如下:

    const pc = new RTCPeerConnection(iceConfig);
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    sendToPeerViaSignalingServer(SIGNALING_OFFER, offer); // 发送方发送信令消息
    

    接收方伪代码如下:

    const pc = new RTCPeerConnection(iceConfig);
    await pc.setRemoteDescription(offer);
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    sendToPeerViaSignalingServer(SIGNALING_ANSWER, answer); // 接收方发送信令消息
    

    候选地址交换服务

    当本地设置了会话描述信息,并添加了媒体流的情况下,ICE框架就会开始收集候选地址。两边收集到候选地址后,需要交换候选地址,并从中知道合适的候选地址对。

    候选地址的交换,同样采用前面提到的信令服务,伪代码如下:

    // 设置本地会话描述信息
    const localPeer = new RTCPeerConnection(iceConfig);
    const offer = await pc.createOffer();
    await localPeer.setLocalDescription(offer);
    
    // 本地采集音视频
    const localVideo = document.getElementById('local-video');
    const mediaStream = await navigator.mediaDevices.getUserMedia({ 
    	video: true, 
    	audio: true
    });
    localVideo.srcObject = mediaStream;
    
    // 添加音视频流
    mediaStream.getTracks().forEach(track => {
    	localPeer.addTrack(track, mediaStream);
    });
    
    // 交换候选地址
    localPeer.onicecandidate = function(evt) {
    	if (evt.candidate) {
    		sendToPeerViaSignalingServer(SIGNALING_CANDIDATE, evt.candidate);
        }
    }
    

    音视频采集

    可以使用浏览器提供的getUserMedia接口,采集本地的音视频。

    const localVideo = document.getElementById('local-video');
    const mediaStream = await navigator.mediaDevices.getUserMedia({ 
    	video: true, 
    	audio: true
    });
    localVideo.srcObject = mediaStream;
    

    音视频发送、接收

    将采集到的音视频轨道,通过addTrack进行添加,发送给远端。

    mediaStream.getTracks().forEach(track => {
    	localPeer.addTrack(track, mediaStream);
    });
    

    远端可以通过监听ontrack来监听音视频的到达,并进行播放。

    remotePeer.ontrack = function(evt) {
        const remoteVideo = document.getElementById('remote-video');
        remoteVideo.srcObject = evt.streams[0];
    }
    

    完整代码

    包含两部分:客户端代码、服务端代码。

    1、客户端代码

    const socket = io.connect('http://localhost:3000');
    
    const CLIENT_RTC_EVENT = 'CLIENT_RTC_EVENT';
    const SERVER_RTC_EVENT = 'SERVER_RTC_EVENT';
    
    const CLIENT_USER_EVENT = 'CLIENT_USER_EVENT';
    const SERVER_USER_EVENT = 'SERVER_USER_EVENT';
    
    const CLIENT_USER_EVENT_LOGIN = 'CLIENT_USER_EVENT_LOGIN'; // 登录
    
    const SERVER_USER_EVENT_UPDATE_USERS = 'SERVER_USER_EVENT_UPDATE_USERS';
    
    const SIGNALING_OFFER = 'SIGNALING_OFFER';
    const SIGNALING_ANSWER = 'SIGNALING_ANSWER';
    const SIGNALING_CANDIDATE = 'SIGNALING_CANDIDATE';
    
    let remoteUser = ''; // 远端用户
    let localUser = ''; // 本地登录用户
    
    function log(msg) {
        console.log(`[client] ${msg}`);
    }
    
    socket.on('connect', function() {
        log('ws connect.');
    });
    
    socket.on('connect_error', function() {
        log('ws connect_error.');
    });
    
    socket.on('error', function(errorMessage) {
        log('ws error, ' + errorMessage);
    });
    
    socket.on(SERVER_USER_EVENT, function(msg) {
        const type = msg.type;
        const payload = msg.payload;
    
        switch(type) {
            case SERVER_USER_EVENT_UPDATE_USERS:
                updateUserList(payload);
                break;
        }
        log(`[${SERVER_USER_EVENT}] [${type}], ${JSON.stringify(msg)}`);
    });
    
    socket.on(SERVER_RTC_EVENT, function(msg) {
        const {type} = msg;
    
        switch(type) {
            case SIGNALING_OFFER:
                handleReceiveOffer(msg);
                break;
            case SIGNALING_ANSWER:
                handleReceiveAnswer(msg);
                break;
            case SIGNALING_CANDIDATE:
                handleReceiveCandidate(msg);
                break;
        }
    });
    
    async function handleReceiveOffer(msg) {
        log(`receive remote description from ${msg.payload.from}`);
        
        // 设置远端描述
        const remoteDescription = new RTCSessionDescription(msg.payload.sdp);
        remoteUser = msg.payload.from;
        createPeerConnection();
        await pc.setRemoteDescription(remoteDescription); // TODO 错误处理
    
        // 本地音视频采集
        const localVideo = document.getElementById('local-video');
        const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
        localVideo.srcObject = mediaStream;
        mediaStream.getTracks().forEach(track => {
            pc.addTrack(track, mediaStream);
            // pc.addTransceiver(track, {streams: [mediaStream]}); // 这个也可以
        });
        // pc.addStream(mediaStream); // 目前这个也可以,不过接口后续会废弃
    
        const answer = await pc.createAnswer(); // TODO 错误处理
        await pc.setLocalDescription(answer);
        sendRTCEvent({
            type: SIGNALING_ANSWER,
            payload: {
                sdp: answer,
                from: localUser,
                target: remoteUser
            }
        });
    }
    
    async function handleReceiveAnswer(msg) {
        log(`receive remote answer from ${msg.payload.from}`);
        
        const remoteDescription = new RTCSessionDescription(msg.payload.sdp);
        remoteUser = msg.payload.from;
    
        await pc.setRemoteDescription(remoteDescription); // TODO 错误处理
    }
    
    async function handleReceiveCandidate(msg){
        log(`receive candidate from ${msg.payload.from}`);
        await pc.addIceCandidate(msg.payload.candidate); // TODO 错误处理
    }
    
    /**
     * 发送用户相关消息给服务器
     * @param {Object} msg 格式如 { type: 'xx', payload: {} }
     */
    function sendUserEvent(msg) {
        socket.emit(CLIENT_USER_EVENT, JSON.stringify(msg));
    }
    
    /**
     * 发送RTC相关消息给服务器
     * @param {Object} msg 格式如{ type: 'xx', payload: {} }
     */
    function sendRTCEvent(msg) {
        socket.emit(CLIENT_RTC_EVENT, JSON.stringify(msg));
    }
    
    let pc = null;
    
    /**
     * 邀请用户加入视频聊天
     *  1、本地启动视频采集
     *  2、交换信令
     */
    async function startVideoTalk() {
        // 开启本地视频
        const localVideo = document.getElementById('local-video');
        const mediaStream = await navigator.mediaDevices.getUserMedia({
            video: true, 
            audio: true
        });
        localVideo.srcObject = mediaStream;
    
        // 创建 peerConnection
        createPeerConnection();
    
        // 将媒体流添加到webrtc的音视频收发器
        mediaStream.getTracks().forEach(track => {
            pc.addTrack(track, mediaStream);
            // pc.addTransceiver(track, {streams: [mediaStream]});
        });
        // pc.addStream(mediaStream); // 目前这个也可以,不过接口后续会废弃
    }
    
    function createPeerConnection() {
        const iceConfig = {"iceServers": [
            {url: 'stun:stun.ekiga.net'},
            {url: 'turn:turnserver.com', username: 'user', credential: 'pass'}
        ]};
        
        pc = new RTCPeerConnection(iceConfig);
    
        pc.onnegotiationneeded = onnegotiationneeded;
        pc.onicecandidate = onicecandidate;
        pc.onicegatheringstatechange = onicegatheringstatechange;
        pc.oniceconnectionstatechange = oniceconnectionstatechange;
        pc.onsignalingstatechange = onsignalingstatechange;
        pc.ontrack = ontrack;
        
        return pc;
    }
    
    async function onnegotiationneeded() {
        log(`onnegotiationneeded.`);
    
        const offer = await pc.createOffer();
        await pc.setLocalDescription(offer); // TODO 错误处理
    
        sendRTCEvent({
            type: SIGNALING_OFFER,
            payload: {
                from: localUser,
                target: remoteUser,
                sdp: pc.localDescription // TODO 直接用offer?
            }
        });
    }
    
    function onicecandidate(evt) {
        if (evt.candidate) {
            log(`onicecandidate.`);
    
            sendRTCEvent({
                type: SIGNALING_CANDIDATE,            
                payload: {
                    from: localUser,
                    target: remoteUser,
                    candidate: evt.candidate
                }
            });
        }
    }
    
    function onicegatheringstatechange(evt) {
        log(`onicegatheringstatechange, pc.iceGatheringState is ${pc.iceGatheringState}.`);
    }
    
    function oniceconnectionstatechange(evt) {
        log(`oniceconnectionstatechange, pc.iceConnectionState is ${pc.iceConnectionState}.`);
    }
    
    function onsignalingstatechange(evt) {
        log(`onsignalingstatechange, pc.signalingstate is ${pc.signalingstate}.`);
    }
    
    // 调用 pc.addTrack(track, mediaStream),remote peer的 onTrack 会触发两次
    // 实际上两次触发时,evt.streams[0] 指向同一个mediaStream引用
    // 这个行为有点奇怪,github issue 也有提到 https://github.com/meetecho/janus-gateway/issues/1313
    let stream;
    function ontrack(evt) {
        // if (!stream) {
        //     stream = evt.streams[0];
        // } else {
        //     console.log(`${stream === evt.streams[0]}`); // 这里为true
        // }
        log(`ontrack.`);
        const remoteVideo = document.getElementById('remote-video');
        remoteVideo.srcObject = evt.streams[0];
    }
    
    // 点击用户列表
    async function handleUserClick(evt) {
        const target = evt.target;
        const userName = target.getAttribute('data-name').trim();
    
        if (userName === localUser) {
            alert('不能跟自己进行视频会话');
            return;
        }
    
        log(`online user selected: ${userName}`);
    
        remoteUser = userName;
        await startVideoTalk(remoteUser);
    }
    
    /**
     * 更新用户列表
     * @param {Array} users 用户列表,比如 [{name: '小明', name: '小强'}]
     */
    function updateUserList(users) {
        const fragment = document.createDocumentFragment();
        const userList = document.getElementById('login-users');
        userList.innerHTML = '';
    
        users.forEach(user => {
            const li = document.createElement('li');
            li.innerHTML = user.userName;
            li.setAttribute('data-name', user.userName);
            li.addEventListener('click', handleUserClick);
            fragment.appendChild(li);
        });    
        
        userList.appendChild(fragment);
    }
    
    /**
     * 用户登录
     * @param {String} loginName 用户名
     */
    function login(loginName) {
        localUser = loginName;
        sendUserEvent({
            type: CLIENT_USER_EVENT_LOGIN,
            payload: {
                loginName: loginName
            }
        });
    }
    
    // 处理登录
    function handleLogin(evt) {
        let loginName = document.getElementById('login-name').value.trim();
        if (loginName === '') {
            alert('用户名为空!');
            return;
        }
        login(loginName);
    }
    
    function init() {
        document.getElementById('login-btn').addEventListener('click', handleLogin);
    }
    
    init();
    

    2、服务端代码

    // 添加ws服务
    const io = require('socket.io')(server);
    let connectionList = [];
    
    const CLIENT_RTC_EVENT = 'CLIENT_RTC_EVENT';
    const SERVER_RTC_EVENT = 'SERVER_RTC_EVENT';
    
    const CLIENT_USER_EVENT = 'CLIENT_USER_EVENT';
    const SERVER_USER_EVENT = 'SERVER_USER_EVENT';
    
    const CLIENT_USER_EVENT_LOGIN = 'CLIENT_USER_EVENT_LOGIN';
    const SERVER_USER_EVENT_UPDATE_USERS = 'SERVER_USER_EVENT_UPDATE_USERS';
    
    function getOnlineUser() {
      return connectionList
      .filter(item => {
        return item.userName !== '';
      })
      .map(item => {
        return {
          userName: item.userName
        };
      });
    }
    
    function setUserName(connection, userName) {
      connectionList.forEach(item => {
        if (item.connection.id === connection.id) {
          item.userName = userName;
        }
      });
    }
    
    function updateUsers(connection) {
      connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()});  
    }
    
    io.on('connection', function (connection) {
    
      connectionList.push({
        connection: connection,
        userName: ''
      });
      
      // 连接上的用户,推送在线用户列表
      // connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()});
      updateUsers(connection);
    
      connection.on(CLIENT_USER_EVENT, function(jsonString) {
        const msg = JSON.parse(jsonString);
        const {type, payload} = msg;
    
        if (type === CLIENT_USER_EVENT_LOGIN) {
          setUserName(connection, payload.loginName);
          connectionList.forEach(item => {
            // item.connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()});
            updateUsers(item.connection);
          });
        }
      });
    
      connection.on(CLIENT_RTC_EVENT, function(jsonString) {
        const msg = JSON.parse(jsonString);
        const {payload} = msg;
        const target = payload.target;
    
        const targetConn = connectionList.find(item => {
          return item.userName === target;
        });
        if (targetConn) {
          targetConn.connection.emit(SERVER_RTC_EVENT, msg);
        }
      });
    
      connection.on('disconnect', function () {
        connectionList = connectionList.filter(item => {
          return item.connection.id !== connection.id;
        });
        connectionList.forEach(item => {
          // item.connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()});
          updateUsers(item.connection);
        });    
      });
    });
    

    写在后面

    WebRTC的API非常多,因为WebRTC本身就比较复杂,随着时间的推移,WebRTC的某些API(包括某些协议细节)也在改动或被废弃,这其中也有向后兼容带来的复杂性,比如本地视频采集后加入传输流,可以采用 addStream 或 addTrack 或 addTransceiver,再比如会话描述版本从plan-b迁移到unified-plan。

    建议亲自动手撸一遍代码,加深了解。

    相关链接

    2019.08.02-video-talk-using-webrtc

    https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection

    onremotestream called twice for each remote stream

  • 相关阅读:
    ZedGraph 总论
    ZedGraph图形控件在Web开发中的应用
    zedgraph基本教程篇第八节PieSampleDemo.cs介绍
    zedgraph基本教程篇第三节、第四节DateAxisSampleDemo.cs和TextAxisSampleDemo.cs介绍
    zedgraph 基本教程篇第二节:ModInitialSampleDemo.cs介绍
    zedgraph基本教程篇第五节BarChartSampleDemo.cs介绍
    zedgraph基本教程篇第九节MasterSampleDemo.cs介绍
    JavaScript 中创建自定义对象
    SQL Server 索引结构及其使用(一)
    Javascript 的基本对象
  • 原文地址:https://www.cnblogs.com/chyingp/p/example-of-video-chat-using-webrtc.html
Copyright © 2020-2023  润新知