• 11┃音视频直播系统之 WebRTC 进行文本聊天并实时传输文件


    一、RTCDataChannel

    • WebRTC 不但可以让你进行音视频通话,而且还可以用它传输普通的二进制数据,比如说可以利用它实现文本聊天、文件的传输等

    • WebRTC 的数据通道(RTCDataChannel)是专门用来传输除了音视频数据之外的任何数据,模仿了 WebSocket 的实现

    • RTCDataChannel 支持的数据类型也非常多,包括:字符串BlobArrayBuffer 以及 ArrayBufferView

    • WebRTC RTCDataChannel 使用的传输协议为 SCTP,即 Stream Control Transport Protocol

    • RTCDataChannel 既可以在可靠的、有序的模式下工作,也可在不可靠的、无序的模式下工作

    • 可靠有序模式(TCP 模式):在这种模式下,消息可以有序到达,但同时也带来了额外的开销,所以在这种模式下消息传输会比较慢

    • 不可靠无序模式(UDP 模式):在此种模式下,不保证消息可达,也不保证消息有序,但在这种模式下没有什么额外开销,所以它非常快

    • 部分可靠模式(SCTP 模式):在这种模式下,消息的可达性和有序性可以根据业务需求进行配置

    • RTCDataChannel 对象是由 RTCPeerConnection 对象创建,其中包含两个参数:

    • 第一个参数:是一个标签(字符串),相当于给 RTCDataChannel 起了一个名字

    • 第二个参数:是 options,包含很多配置,其中就可以设置上面说的模式,重试次数等

    // 创建 RTCPeerConnection 对象
    var pc = new RTCPeerConnection();
    
    // 创建 RTCDataChannel 对象
    var dc = pc.createDataChannel("dc", {
        ordered: true // 保证到达顺序
    });
    
    // options参数详解, 前三项是经常使用的:
    // ordered:消息的传递是否有序
    // maxPacketLifeTime:重传消息失败的最长时间
    // maxRetransmits:重传消息失败的最大次数
    // protocol:用户自定义的子协议, 默认为空
    // negotiated:如果为 true,则会删除另一方数据通道的自动设置
    // id:当 negotiated 为 true 时,允许你提供自己的 ID 与 channel 进行绑定
    
    // dc的事件处理与 WebSocket 的事件处理非常相似
    dc.onerror = (error) => {
        // 出错的处理
    };
    dc.onopen = () => {
        // 打开的处理
    };
    dc.onclose = () => {
        // 关闭的处理
    };
    dc.onmessage = (event) => {
        // 收到消息的处理
        var msg = event.data;
    };

     

    二、文本聊天

    • 点击 Start 按钮时,会调用 start方法获取视频流然后 调用 conn 方法

    • 然后调用 io.connect() 连接信令服务器,然后再根据信令服务器下发的消息做不同的处理

    • 数据的发送非常简单,当用户点击 Send 按钮后,文本数据就会通过 RTCDataChannel 传输到远端

    • 对于接收数据,则是通过 RTCDataChannel onmessage 事件实现的

    • RTCDataChannel 对象的创建要在媒体协商(offer/answer) 之前创建,否则 WebRTC 就会一直处于 connecting 状态,从而导致数据无法进行传输

    • RTCDataChannel 对象是可以双向传输数据的,所以接收与发送使用一个RTCDataChannel 对象即可,而不需要为发送和接收单独创建 RTCDataChannel 对象

    <!DOCTYPE html>
    <html lang="en">
    
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
        <style>
            .preview {
                display: flex;
            }
    
            .remote {
                margin-left: 20px;
            }
    
            .text_chat {
                display: flex;
            }
    
            .text_chat textarea {
                width: 350px;
                height: 350px;
            }
    
            .send {
                margin-top: 20px;
            }
        </style>
    </head>
    
    <body>
        <div>
            <div>
                <button onclick="start()">连接信令服务器</button>
                <button onclick="leave()" disabled>断开连接</button>
            </div>
    
            <div class="preview">
                <div>
                    <h2>本地:</h2>
                    <video id="localvideo" autoplay playsinline></video>
                </div>
                <div class="remote">
                    <h2>远端:</h2>
                    <video id="remotevideo" autoplay playsinline></video>
                </div>
            </div>
            <!--文本聊天-->
            <h2>聊天:</h2>
            <div class="text_chat">
                <div>
                    <textarea id="chat" disabled></textarea>
                </div>
                <div class="remote">
                    <textarea id="sendtext" disabled></textarea>
                </div>
            </div>
            <div class="send">
                <button onclick="send()" disabled>发送</button>
            </div>
        </div>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js"></script>
        <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
    </body>
    <script>
        'use strict'
    
        var localVideo = document.querySelector('video#localvideo');
        var remoteVideo = document.querySelector('video#remotevideo');
    
        // 文本聊天
        var chat = document.querySelector('textarea#chat');
        var send_txt = document.querySelector('textarea#sendtext');
    
        var localStream = null;
    
        var roomid = '44444';
        var socket = null;
    
        var state = 'init';
    
        var pc = null;
        var dc = null;
    
        function sendMessage(roomid, data) {
            socket.emit('message', roomid, data);
        }
    
        function getAnswer(desc) {
            pc.setLocalDescription(desc);
            // 发送信息
            socket.emit('message', roomid, desc);
        }
    
        function handleAnswerError(err) {
            console.error('Failed to get Answer!', err);
        }
    
        //接收远端流通道
        function call() {
            if (state === 'joined_conn') {
                if (pc) {
                    var options = {
                        offerToReceiveAudio: 1,
                        offerToReceiveVideo: 1
                    }
                    pc.createOffer(options)
                        .then(function (desc) {
                            pc.setLocalDescription(desc);
                            socket.emit('message', roomid, desc);
                        })
                        .catch(function (err) {
                            console.error('Failed to get Offer!', err);
                        });
                }
            }
        }
    
        //文本对方传过来的数据
        function reveivemsg(e) {
            var msg = e.data;
            console.log('recreived msg is :' + e.data);
            if (msg) {
                chat.value += '->' + msg + '\r\n';
            } else {
                console.error('recreived msg is null');
            }
        }
    
        function dataChannelStateChange() {
            var readyState = dc.readyState;
            if (readyState === 'open') {
                send_txt.disabled = false;
                btnSend.disabled = false;
            } else {
                send_txt.disabled = true;
                btnSend.disabled = true;
            }
        }
    
        function dataChannelError(error) {
            console.log("Data Channel Error:", error);
        }
    
        function conn() {
            //1 触发socke连接
            socket = io.connect();
    
            //2 加入房间后的回调
            socket.on('joined', (roomid, id) => {
    
                state = 'joined';
    
                createPeerConnection();
    
                btnConn.disabled = true;
                btnLeave.disabled = false;
    
                console.log("reveive joined message:state=", state);
            });
            socket.on('otherjoin', (roomid, id) => {
    
                if (state === 'joined_unbind') {
                    createPeerConnection();
                }
    
                var dataChannelOptions = {
                    ordered: true, //保证到达顺序
                };
                //文本聊天
                dc = pc.createDataChannel('dataChannel', dataChannelOptions);
                dc.onmessage = reveivemsg;
                dc.onopen = dataChannelStateChange;
                dc.onclose = dataChannelStateChange;
                dc.onerror = dataChannelError;
    
    
                state = 'joined_conn';
    
                //媒体协商
                call();
                console.log("reveive otherjoin message:state=", state);
            });
            socket.on('full', (roomid, id) => {
                console.log('receive full message ', roomid, id);
    
                closePeerConnection();
                closeLocalMedia();
    
                state = 'leaved';
    
                btnConn.disabled = false;
                btnLeave.disabled = true;
                console.log("reveive full message:state=", state);
                alert("the room is full!");
            });
    
            socket.on('leaved', (roomid, id) => {
    
                state = 'leaved';
                socket.disconnect();
                btnConn.disabled = false;
                btnLeave.disabled = true;
                console.log("reveive leaved message:state=", state);
            });
    
            socket.on('bye', (roomid, id) => {
    
                state = 'joined_unbind';
                closePeerConnection();
                console.log("reveive bye message:state=", state);
            });
            socket.on('disconnect', (socket) => {
                console.log('receive disconnect message!', roomid);
                if (!(state === 'leaved')) {
                    closePeerConnection();
                    closeLocalMedia();
                }
                state = 'leaved';
    
            });
            socket.on('message', (roomid, id, data) => {
                console.log(" message=====>", data);
                //媒体协商
                if (data) {
                    if (data.type === 'offer') {
                        pc.setRemoteDescription(new RTCSessionDescription(data));
                        pc.createAnswer()
                            .then(getAnswer)
                            .catch(handleAnswerError);
                    } else if (data.type === 'answer') {
                        console.log("reveive client message=====>", data);
                        pc.setRemoteDescription(new RTCSessionDescription(data));
                    } else if (data.type === 'candidate') {
                        var candidate = new RTCIceCandidate({
                            sdpMLineIndex: data.label,
                            candidate: data.candidate
                        });
                        pc.addIceCandidate(candidate);
    
                    } else {
                        console.error('the message is invalid!', data)
                    }
                }
    
                console.log("reveive client message", roomid, id, data);
            });
    
            socket.emit('join', roomid);
            return;
        }
    
        function start() {
            if (!navigator.mediaDevices ||
                !navigator.mediaDevices.getUserMedia) {
                console.log("getUserMedia is not supported!")
                return;
            }
    
            navigator.mediaDevices.getUserMedia({
                video: true,
                audio: false
            })
                .then(function (stream) {
                    localStream = stream;
                    localVideo.srcObject = localStream;
                    conn();
                })
                .catch(function (err) {
                    console.error("getUserMedia  error:", err);
                })
        }
    
        function leave() {
            if (socket) {
                socket.emit('leave', roomid);
            }
    
            //释放资源
            closePeerConnection();
            closeLocalMedia();
    
            btnConn.disabled = false;
            btnLeave.disabled = true;
        }
    
        //关闭流通道
        function closeLocalMedia() {
            if (localStream && localStream.getTracks()) {
                localStream.getTracks().forEach((track) => {
                    track.stop();
                });
            }
            localStream = null;
        }
    
        //关闭本地媒体流链接
        function closePeerConnection() {
            console.log('close RTCPeerConnection!');
            if (pc) {
                pc.close();
                pc = null;
            }
        }
    
        //创建本地流媒体链接
        function createPeerConnection() {
            console.log('create RTCPeerConnection!');
            if (!pc) {
                pc = new RTCPeerConnection({
                    'iceServers': [{
                        'urls': 'turn:127.0.0.1:8000',
                        'credential': '123456',
                        'username': 'autofelix'
                    }]
                });
    
                pc.onicecandidate = (e) => {
                    if (e.candidate) {
                        sendMessage(roomid, {
                            type: 'candidate',
                            label: e.candidate.sdpMLineIndex,
                            id: e.candidate.sdpMid,
                            candidate: e.candidate.candidate
                        });
                    }
                }
    
                //文本聊天
                pc.ondatachannel = e => {
                    dc = e.channel;
                    dc.onmessage = reveivemsg;
                    dc.onopen = dataChannelStateChange;
                    dc.onclose = dataChannelStateChange;
                    dc.onerror = dataChannelError;
                }
    
                pc.ontrack = (e) => {
                    remoteVideo.srcObject = e.streams[0];
                }
            }
    
            if (pc === null || pc === undefined) {
                console.error('pc is null or undefined!');
                return;
            }
    
            if (localStream === null || localStream === undefined) {
                console.error('localStream is null or undefined!');
                return;
            }
    
            if (localStream) {
                localStream.getTracks().forEach((track) => {
                    pc.addTrack(track, localStream);
                })
            }
        }
    
        //发送文本
        function send() {
            var data = send_txt.value;
            if (data) {
                dc.send(data);
            }
            send_txt.value = "";
            chat.value += '<-' + data + '\r\n';
        }
    </script>
    
    </html>

     

    三、文件传输

    • 实时文件的传输与实时文本消息传输的基本原理是一样的,都是使用 RTCDataChannel 对象进行传输

    • 它们的区别一方面是传输数据的类型不一样,另一方面是数据的大小不一样

    • 在传输文件的时候,必须要保证文件传输的有序性和完整性,所以需要设置 ordered 和 maxRetransmits 选项

    • 发送数据如下:

    // 创建 RTCDataChannel 对象的选项
    var options = {
        ordered: true,
        maxRetransmits: 30 // 最多尝试重传 30 次
    };
    
    // 创建 RTCPeerConnection 对象
    var pc = new RTCPeerConnection();
    
    // 方法一:通过通道发送
    sendChannel = pc.createDataChannel(name, options);
    sendChannel.addEventListener('open', onSendChannelStateChange); //打开之后才可以传输数据 
    sendChannel.addEventListener('close', onSendChannelStateChange);
    sendChannel.send(JSON.stringify({
        // 将文件信息以 JSON 格式发磅
        type: 'fileinfo',
        name: file.name,
        size: file.size,
        filetype: file.type,
        lastmodify: file.lastModified
    }));
    
    // 方法二:通过arraybuffer发送
    var offset = 0; // 偏移量
    var chunkSize = 16384; // 每次传输的块大小
    var file = fileInput.files[0]; // 要传输的文件,它是通过 HTML 中的 file 获取的
    
    // 创建 fileReader 来读取文件
    fileReader = new FileReader();
    
    // 当数据被加载时触发该事件
    fileReader.onload = e => {
        // 发送数据
        dc.send(e.target.result);
        offset += e.target.result.byteLength; // 更改已读数据的偏移量
    
        if (offset < file.size) { // 如果文件没有被读完
            readSlice(offset); // 读取数据
        }
    }
    
    var readSlice = o => {
        const slice = file.slice(offset, o + chunkSize); // 计算数据位置
        fileReader.readAsArrayBuffer(slice); // 读取 16K 数据
    };
    readSlice(0); // 开始读取数据
    • 接收数据如下:

    • 当有数据到达时就会触发该事件就会触发 onmessage 事件

    • 只需要简单地将收到的这块数据 push 到 receiveBuffer 数组中即可

    var receiveBuffer = []; // 存放数据的数组
    var receiveSize = 0; // 数据大小
    
    onmessage = (event) => {
        // 每次事件被触发时,说明有数据来了,将收到的数据放到数组中
        receiveBuffer.push(event.data);
        // 更新已经收到的数据的长度
        receivedSize += event.data.byteLength;
        // 如果接收到的字节数与文件大小相同,则创建文件
        if (receivedSize === fileSize) { //fileSize 是通过信令传过来的
            // 创建文件
            var received = new Blob(receiveBuffer, { type: 'application/octet-stream' });
            // 将 buffer 和 size 清空,为下一次传文件做准备
            receiveBuffer = [];
            receiveSize = 0;
            // 生成下载地址
            downloadAnchor.href = URL.createObjectURL(received);
            downloadAnchor.download = fileName;
            downloadAnchor.textContent = `Click to download '${fileName}' (${fileSize} bytes)`;
            downloadAnchor.style.display = 'block';
        }
    }

     

  • 相关阅读:
    JSP内置对象及作用域(三)
    循环执行某段代码,待某种条件满足后停止循环 java原始Timer实现
    SpringBoot轻松实现ip解析(含源码)
    回归预测 | MATLAB实现PSO-SVR粒子群优化支持向量机回归多输入单输出预测
    Lock锁以及解决生产者消费者的方案
    帅尔康衬衫直销网站设计与开发
    基于MOOC的FPGA硬件类课程远程实验教学平台设计
    企业微信SCRM的那些事儿
    ubuntu搭建opencv开发环境
    介绍一款免费的文件内容搜索工具
  • 原文地址:https://www.cnblogs.com/autofelix/p/16292181.html