WebRTC直播间搭建记录

发布于:2024-04-16 ⋅ 阅读:(19) ⋅ 点赞:(0)

考虑到后续增加平台直播的可能性,笔记记录一下WebRTC相关.

在这里插入图片描述

让我们分别分析两种情况下的WebRTC连接建立过程:

情况一:AB之间可以直接通信

1.信令交换:
设备A和设备B首先通过信令服务器交换SDP(Session Description Protocol)信息和候选者(candidates)。SDP包含有关会话的信息,包括设备的媒体能力和网络地址。

2.ICE框架协商:
设备A和设备B使用ICE框架收集本地候选者(本地网络地址),然后交换候选者信息,包括通过STUN服务器获取的公共IP地址和端口号。

3.连接建立:
根据ICE框架收集的候选者信息,设备A和设备B尝试直接建立点对点连接。根据候选者优先级排序,选择最佳的候选者进行连接。

4.直接通信:
如果ICE框架成功建立了连接,设备A和设备B之间可以直接进行实时通信,例如音频、视频或数据传输。

情况二:AB之间不能直接通信
1.信令交换:
设备A和设备B通过信令服务器交换SDP信息和候选者。

2.ICE框架协商:
设备A和设备B分别收集本地候选者,并将候选者信息发送给对方。

3.无法直接建立连接:
如果ICE框架无法直接建立连接(例如由于双方都位于NAT环境或防火墙后),则ICE框架会返回无法建立连接的错误或超时。

4.使用TURN服务器:
在无法直接建立连接的情况下,设备A和设备B将使用TURN服务器作为中继器来中转数据流。设备A和设备B分别连接到TURN服务器,并将数据流通过TURN服务器进行中转,从而实现对等通信。

什么情况下AB不能直接建立点对点连接?

双方位于不同的私有网络:如果设备A和设备B都处于不同的私有网络(例如家庭网络),则无法直接访问对方的局域网地址。

AB之间无法直接建立点对点连接的情况通常是由于网络地址转换(NAT)或防火墙的存在,导致设备无法直接接收对方发送的数据包。具体情况包括:

NAT类型限制:如果设备A或设备B位于对称NAT或受限NAT网络中,会导致UDP包的发送和接收出现问题,从而无法建立直接连接。

防火墙限制:防火墙可以阻止对UDP或特定端口的访问,这会影响设备之间的直接通信。

公共IP地址不可用:有些设备可能没有公共IP地址,而是通过NAT路由器共享局域网IP地址。

在这些情况下,WebRTC利用ICE框架和STUN/TURN服务器来实现网络穿透,确保设备之间可以建立可靠的实时通信连接。通过STUN服务器获取公共网络地址,通过TURN服务器进行数据中转,解决了设备间无法直接通信的问题。

附一下个人直播间搭建部分代码

<html>

<head>
    <title>简单直播间</title>
    <style type="text/css">
    body {
        background: #888888 center center no-repeat;
        color: white;
    }

    button {
        cursor: pointer;
        user-select: none;
    }

    .room {
        border: 1px solid black;
        cursor: pointer;
        user-select: none;
        text-align: center;
        background: rgba(0, 0, 0, 0.5);
    }

    video {
        width: 75%;
        border: 2px solid black;
        border-image: linear-gradient(#F80, #2ED) 20 20;
    }

    #chat {
        position: fixed;
        top: 5px;
        right: 5px;
        width: calc(25% - 30px);
        height: calc(75% - 10px);
        background: rgba(0, 0, 0, 0.5);
    }

    .content {
        height: calc(100% - 66px);
        margin-top: 5px;
        margin-bottom: 5px;
        overflow-y: scroll;
    }

    #chatSend {
        white-space: nowrap;
    }

    #chatSend>input {
        width: calc(100% - 90px);
    }

    #chatSend>button {
        width: 80px;
    }

    #chatTag {
        margin-left: 10px;
        line-height: 30px;
    }

    .chatKuang {
        border: 1px solid white;
        margin: 3px;
        border-radius: 5px;
    }

    .hintKuang {
        margin: 3px;
        text-align: center;
    }
    </style>
</head>

<body>
    <div id="create">
        名称:<input id="name" type="text" />
        <button onclick="createRoom()">创建直播间</button>
        <span id="count"></span>
    </div>
    <video id="localVideo" autoplay controls="controls"></video>
    <br />
    <div id="chat">
        <div id="chatTag">
            <button onclick="changeTag(0)">房间列表</button>
            <button onclick="changeTag(1)">房间聊天</button>
        </div>
        <div id="roomContent" class="content"></div>
        <div id="chatContent" class="content" style="display: none"></div>
        <div id="chatSend">
            <input type="text" />
            <button onclick="chatSend()">发送</button>
        </div>
    </div>
    <script type="text/javascript">
    // 背景图片
    (function() {
        var img = new Image();
        img.addEventListener("load", function() {
            document.querySelector("body").style.background =
                "url('" + this.src + "') center center no-repeat";
            let _img = this;
            calculateBackgroundImageScale(_img);
            window.onresize = function(_img) {
                calculateBackgroundImageScale(_img);
            }
        });

        img.src = "https://parva.cool/share/sky043.jpg";
    })();

    //计算背景图片缩放(自适应窗口大小)
    function calculateBackgroundImageScale(img) {
        let w1 = document.body.clientWidth;
        let h1 = document.body.clientHeight;
        let w2 = img.width;
        let h2 = img.height;
        let scale1 = w1 / w2;
        let scale2 = h1 / h2;
        let scale = scale1 > scale2 ? scale1 : scale2;
        document.querySelector("body").style.backgroundSize =
            Math.ceil(w2 * scale) + "px " + Math.ceil(h2 * scale) + "px";
    }

    // 存储本地媒体流
    var localStream;

    // 与服务器的websocket通信
    var socket = new WebSocket("wss://parva.cool/rtc");

    // 判断自己是否正在直播
    var isMe;

    // 当前的标签页(0:房间列表, 1:房间聊天)
    var tag = 0;

    // 发送聊天信息
    function chatSend() {
        let input = document.querySelector("#chatSend input");
        let msg = input.value;
        input.value = "";
        if (Object.keys(pcs).length == 0) return;
        if (isMe) {
            socket.send(JSON.stringify({ event: "chatSend", msg: msg }));
        } else {
            socket.send(JSON.stringify({
                event: "chatSend",
                msg: msg,
                roomName: Object.keys(pcs)[0]
            }));
        }
    }

    // 切换标签
    function changeTag(t) {
        tag = t;
        document.querySelector("#roomContent").style.display = "none";
        document.querySelector("#chatContent").style.display = "none";
        if (tag == 0) {
            document.querySelector("#roomContent").style.display = "block";
        } else if (tag == 1) {
            document.querySelector("#chatContent").style.display = "block";
        }
    }

    // 创建直播间
    function createRoom() {
        let roomName = document.querySelector("#name").value;
        if (!localStream) {
            navigator.mediaDevices.getDisplayMedia({ video: true, audio: true })
                .then((stream) => {
                    localStream = stream;
                    socket.send(JSON.stringify({
                        event: "createRoom",
                        roomName: roomName
                    }));
                });
        } else {
            socket.send(JSON.stringify({
                event: "createRoom",
                roomName: roomName
            }));
        }
    }

    // 关闭直播间
    function closeRoom() {
        socket.send(JSON.stringify({ event: "closeRoom" }));
        document.querySelector("input").disabled = false;
        document.querySelector("button").innerHTML = "创建直播间";
        document.querySelector("button").setAttribute("onclick",
            "createRoom()");
        document.querySelector("video").srcObject = null;
        pcs = {};
        let content = document.querySelector("#chatContent");
        content.innerHTML = "";
        changeTag(0);
        let count = document.querySelector("#count");
        count.innerHTML = "";
        localStream.getTracks()[0].stop();
    }

    // 进入直播间
    var 防止双击;
    var 防止双击setTimeout;

    function joinRoom(roomName) {
        if (防止双击 == roomName) return;
        else 防止双击 = roomName;
        clearTimeout(防止双击setTimeout);
        防止双击setTimeout = setTimeout(function() { 防止双击 = ""; }, 800);
        let name = document.querySelector("#name").value;
        socket.send(JSON.stringify({
            event: "joinRoom",
            name: name,
            roomName: roomName
        }));
    }

    // 接收服务器的消息
    socket.onmessage = function(event) {
        let json = JSON.parse(event.data);

        // 接收聊天信息
        if (json.event === "chat") {
            let chatName = document.createElement("span");
            chatName.innerHTML = json.name + " : ";
            chatName.setAttribute("class", "chatName");
            let chatMessage = document.createElement("span");
            chatMessage.innerHTML = json.msg;
            chatName.setAttribute("class", "chatMessage");
            let chatKuang = document.createElement("div");
            chatKuang.setAttribute("class", "chatKuang");
            chatKuang.appendChild(chatName);
            chatKuang.appendChild(chatMessage);
            let content = document.querySelector("#chatContent");
            content.appendChild(chatKuang);
            content.scrollTop = 9999999;
        }

        // 通知新人加入房间
        if (json.event === "joinHint") {
            let hintKuang = document.createElement("div");
            hintKuang.setAttribute("class", "hintKuang");
            hintKuang.innerHTML = json.name + "加入直播房间!"
            let content = document.querySelector("#chatContent");
            content.appendChild(hintKuang);
            content.scrollTop = 9999999;
        }

        // 有人退出当前房间
        if (json.event === "quitHint") {
            let hintKuang = document.createElement("div");
            hintKuang.setAttribute("class", "hintKuang");
            hintKuang.innerHTML = json.name + "退出房间.."
            let content = document.querySelector("#chatContent");
            content.appendChild(hintKuang);
            content.scrollTop = 9999999;
        }

        // 接收房间人数
        if (json.event === "count") {
            let count = document.querySelector("#count");
            count.innerHTML = "\t在场众神数量 : " + json.count;
        }

        // 所有房间的信息
        if (json.event === "roomsInfo") {
            if (tag != 0) return;
            let content = document.querySelector("#roomContent");
            content.innerHTML = "";
            for (let i = 0; i < json.info.length; i++) {
                let div = document.createElement("div");
                div.innerHTML = json.info[i];
               
                //点击进入房间事件捆绑
                div.setAttribute("onclick", "joinRoom('" + json.info[i] + "')");
                div.setAttribute("class", "room");
                content.appendChild(div);
            }
        }

        // 创建房间失败
        if (json.event === "createRoomFailed") {
            alert("创建房间失败,名称已存在 或 名称格式有误");
            localStream.getTracks()[0].stop();
        }

        // 创建房间成功
        if (json.event === "createRoomOk") {
            document.querySelector("input").disabled = true;
            document.querySelector("button").innerHTML = "关闭直播间";
            document.querySelector("button").setAttribute("onclick",
                "closeRoom()");
           
           //将捕捉的本地媒体流赋值到直播窗口
            document.querySelector("video").srcObject = localStream;
            document.querySelector("video").volume = 0;
            isMe = true;
            changeTag(1);
            let hintKuang = document.createElement("div");
            hintKuang.setAttribute("class", "hintKuang");
            hintKuang.innerHTML = "创建直播房间成功!"
            let content = document.querySelector("#chatContent");
            content.appendChild(hintKuang);
            content.scrollTop = 9999999;
        }

        // 加入房间失败
        if (json.event === "joinRoomFailed") {
            alert("加入房间失败,名称已存在 或 名称格式有误");
        }

        // 主播下播,退出房间
        if (json.event === "roomClosed") {
            document.querySelector("video").srcObject = null;
            pcs = {};
            let content = document.querySelector("#chatContent");
            content.innerHTML = "";
            changeTag(0);
            document.querySelector("input").disabled = false;
            let count = document.querySelector("#count");
            count.innerHTML = "";
        }

        // 有人欲加入我的直播间
        if (json.event === "joinRoom") {
            // 为对方创建一个pc实例,并发送offer给他
            var pc = createRTCPeerConnection(json.name);
            // rtc建立连接:创建一个offer给对方
            pc.createOffer(function(desc) {
                pc.setLocalDescription(desc);
                socket.send(JSON.stringify({
                    event: "_offer",
                    data: {
                        sdp: desc,
                        nameB: json.name
                    }
                }));
            }, function(error) {
                console.log("CreateOffer Failure callback: " + error);
            });
        }

        // rtc建立连接:接收到offer
        if (json.event === "_offer") {
            // 为对方创建一个pc实例,并发送offer给他
            var pc = createRTCPeerConnection(json.data.nameA);
            pc.setRemoteDescription(new RTCSessionDescription(json.data.sdp));
            // rtc建立连接:创建一个answer给对方
            pc.createAnswer(function(desc) {
                pc.setLocalDescription(desc);
                socket.send(JSON.stringify({
                    event: "_answer",
                    data: {
                        sdp: desc,
                        nameA: json.data.nameA
                    }
                }));
            }, function(error) {
                console.log("CreateAnswer Failure callback: " + error);
            });
        }

        // rtc建立连接:接收到answer   --A收到B的answer
        // 将来自对方的 Answer SDP 设置为本地 WebRTC 连接的远程描述,以便双方能够正确地理解和处理对方的媒体数据,从而建立成功的实时通信连接。
        if (json.event === "_answer") {

            pcs[json.data.nameB].setRemoteDescription(
                new RTCSessionDescription(json.data.sdp));
        }

        // rtc建立连接:接收到_ice_candidate
        if (json.event === "_ice_candidate") {
            pcs[json.data.from].addIceCandidate(
                new RTCIceCandidate(json.data.candidate));
        }
    }

    // Q:一对多直播情况下 直播用户A的远程描述需要怎么变化?
    // A:连接都是独立的 分别设置对应的远端描述符 A的描述符不变



    // 存储pc实例
    var pcs = {};

    // stun和turn服务器URL及配置
    var iceServer = {
        iceServers: [
            { urls: "stun:parva.cool:3478" },
            {
                urls: "turn:parva.cool:3478",
                username: "parva",
                credential: "Parva089"
            }
        ]
    };

    // 创建RTCPeerConnection实例
    function createRTCPeerConnection(name) {
        let pc = new RTCPeerConnection(iceServer);
        if (!isMe) {
            for (let n in pcs) pcs[n].close();
            pcs = {};
        }
        // 以{对方的名字:PC实例}键值对形式把PC实例存储起来
        pcs[name] = pc;
        if (localStream) pc.addStream(localStream);
        else pc.addStream(new MediaStream());

        pc.onicecandidate = function(event) {
            if (event.candidate !== null)
                socket.send(JSON.stringify({
                    event: "_ice_candidate",
                    data: {
                        candidate: event.candidate,
                        to: Object.keys(pcs).find(k => pcs[k] == pc)
                    }
                }));
        }

        pc.ontrack = function(event) {
            if (isMe) return;
            changeTag(1);
            document.querySelector("video").srcObject = event.streams[0];
            document.querySelector("input").disabled = true;
            let hintKuang = document.createElement("div");
            hintKuang.setAttribute("class", "hintKuang");
            hintKuang.innerHTML = "成功进入直播间!"
            let content = document.querySelector("#chatContent");
            content.innerHTML = "";
            content.appendChild(hintKuang);
            content.scrollTop = 9999999;
        }

        return pc;
    }
    </script>
</body>

</html>

建立 BC 和 A 之间的 WebRTC 连接涉及以下步骤和流程:

前提条件

A 是直播主播,已经在直播间创建了媒体流并开始直播。
B 和 C 是观众,希望加入直播间并观看 A 的直播。

建立 WebRTC 连接的过程:
1.B 或 C 加入直播间:
B 或 C 在前端页面选择要加入的直播间并点击加入按钮。
前端通过 WebSocket 向服务器发送加入房间的请求,包括用户信息和房间名称。

2.服务器收到加入房间请求:
后端服务器接收到 B 或 C 的加入房间请求,将其添加到对应房间的用户列表中。

3.A 发送 WebRTC offer 给 B 或 C:
当 B 或 C成功加入房间后,后端服务器会通知 A(主播)有新用户加入了直播间。
A 在前端收到加入房间的通知后,使用 WebRTC 创建一个 PeerConnection 对象(pc),并生成一个 SDP offer。
A 将这个 SDP offer 通过 WebSocket 发送给服务器。

4.服务器转发 WebRTC offer 给 B 或 C:
后端服务器收到 A 发送的 SDP offer。
后端服务器将 SDP offer 转发给房间内除 A 之外的其他用户(即 B 或 C)。

5.B 或 C 接收 WebRTC offer:
B 或 C 前端收到 A 发送的 SDP offer。
B 或 C 使用 WebRTC 创建一个 PeerConnection 对象(pc),并设置 A 的 SDP offer 作为远端描述(setRemoteDescription)。

6.B 或 C 创建 WebRTC answer 给 A:
B 或 C 使用自己的本地媒体流(视频和音频)创建一个 SDP answer。
B 或 C 将这个 SDP answer 发送给服务器。

7.服务器转发 WebRTC answer 给 A:
后端服务器收到 B 或 C 发送的 SDP answer。
后端服务器将 SDP answer 转发给 A。

8.A 接收 WebRTC answer:
A 前端收到 B 或 C 发送的 SDP answer。
A 设置 B 或 C 的 SDP answer 作为远端描述(setRemoteDescription)。

9.ICE 候选者交换:
PeerConnection 开始收集和交换 ICE 候选者信息(网络地址、端口等)。

10.B、C 和 A 通过服务器交换 ICE 候选者信息,以便彼此建立直接的通信路径。
建立直播观看连接:

完成以上步骤后,B 或 C 和 A 之间的 WebRTC 连接就建立起来了。
B 或 C 的本地媒体流通过 ICE 候选者的协商直接传输到 A,A 的直播内容会被 B 或 C 观看。

总结:

通过以上步骤,B 或 C 可以加入 A 创建的直播间,并与 A 建立起 WebRTC 连接,实现实时的音视频传输和观看直播内容。整个过程涉及前端的用户交互和媒体流处理,以及后端的 WebRTC 信令传递和 ICE 候选者交换,共同实现了观众与主播之间的实时通信和直播功能。