You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于licenseID的WebRTC直播系统:客户端无法观看直播问题排查

问题:观众无法观看主播直播内容

我正在搭建一个可独立部署的直播服务器,通过licenseID关联主播和观众,支持多场景连接:比如主播A用licenseID 1开播,主播B用licenseID 2开播,观众可以通过对应licenseID切换观看不同主播,也能退出观看。但现在遇到的问题是,观众客户端无法看到主播的直播内容。

服务器代码

const express = require("express");
const http = require("http");
const socketIo = require("socket.io");
const cors = require("cors");

const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
    cors: {
        origin: "*",
        methods: ["GET", "POST"]
    }
});

const port = 4000;

// 用Map存储活跃主播的licenseID与socketID对应关系
const broadcasters = new Map();
app.use(cors());

// Socket连接处理
io.on("connection", socket => {
    console.log(`Socket已连接: ${socket.id}`);

    // 处理主播注册
    socket.on("broadcaster", (licenseID) => {
        broadcasters.set(licenseID, socket.id);
        console.log(`主播已注册: LicenseID ${licenseID}`);
    });

    // 处理观众观看请求
    socket.on("watcher", (licenseID) => {
        const broadcasterSocketId = broadcasters.get(licenseID);
        if (broadcasterSocketId) {
            socket.to(broadcasterSocketId).emit("watcher", socket.id);
            console.log(`观众已连接到主播 ${licenseID}`);
        } else {
            socket.emit("error", "当前licenseID没有可用主播");
        }
    });

    // WebRTC信令转发
    socket.on("offer", (to, message) => {
        socket.to(to).emit("offer", socket.id, message);
    });

    socket.on("answer", (to, message) => {
        socket.to(to).emit("answer", socket.id, message);
    });

    socket.on("candidate", (to, message) => {
        socket.to(to).emit("candidate", socket.id, message);
    });

    // 断开连接处理
    socket.on("disconnect", () => {
        console.log(`Socket已断开: ${socket.id}`);
        // 清理主播Map
        broadcasters.forEach((value, key) => {
            if (value === socket.id) {
                broadcasters.delete(key);
            }
        });
    });
});

// 静态文件服务
app.use(express.static(__dirname + "/public"));

// 启动服务器
server.listen(port, () => {
    console.log(`服务器运行在端口 ${port}`);
});

原主播端代码(存在问题)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Broadcaster</title>
    <script src="/socket.io/socket.io.js"></script>
    <script>
        const socket = io.connect('http://localhost:4000');
        let peerConnection;
        const licenseID = 1; 

        const config = {
            iceServers: [
                { urls: 'stun:stun.l.google.com:19302' }
            ]
        };

        socket.emit('broadcaster', licenseID);

        // Start video streaming
        navigator.mediaDevices.getUserMedia({ video: true, audio: true })
            .then(stream => {
                const localVideo = document.getElementById('localVideo');
                localVideo.srcObject = stream;
                peerConnection = new RTCPeerConnection(config);
                stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));

                peerConnection.onicecandidate = (event) => {
                    if (event.candidate) {
                        socket.emit('candidate', licenseID, { candidate: event.candidate, to: 'watcher_id' });
                    }
                };

                peerConnection.createOffer()
                    .then(offer => peerConnection.setLocalDescription(offer))
                    .then(() => {
                        socket.emit('offer', 'watcher_id', { description: peerConnection.localDescription });
                    })
                    .catch(error => {
                        console.error('Error creating offer:', error);
                    });

                socket.on('answer', (from, message) => {
                    peerConnection.setRemoteDescription(new RTCSessionDescription(message.description))
                        .catch(error => {
                            console.error('Error setting remote description:', error);
                        });
                });

                socket.on('candidate', (from, message) => {
                    peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate))
                        .catch(error => {
                            console.error('Error adding ICE candidate:', error);
                        });
                });
            })
            .catch(error => {
                console.error('Error accessing media devices: ', error);
            });
    </script>
</head>
<body>
    <h1>Broadcaster</h1>
    <video id="localVideo" autoplay muted></video>
</body>
</html>

原观众端代码(存在问题)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Watcher</title>
    <script src="/socket.io/socket.io.js"></script>
    <script>
        const socket = io.connect('http://localhost:4000');
        let peerConnection;
        const licenseID = 1; 

        const config = {
            iceServers: [
                { urls: 'stun:stun.l.google.com:19302' }
            ]
        };

        socket.emit('watcher', licenseID);

        socket.on('offer', async (from, message) => {
            console.log("Received offer from broadcaster:", from, message);
            peerConnection = new RTCPeerConnection(config);

            peerConnection.ontrack = (event) => {
                const remoteVideo = document.getElementById('remoteVideo');
                remoteVideo.srcObject = event.streams[0];
            };

            peerConnection.onicecandidate = (event) => {
                if (event.candidate) {
                    socket.emit('candidate', 'broadcaster_id', { candidate: event.candidate, to: from });
                }
            };

            try {
                await peerConnection.setRemoteDescription(new RTCSessionDescription(message.description));
                const answer = await peerConnection.createAnswer();
                await peerConnection.setLocalDescription(answer);

                socket.emit('answer', from, { description: peerConnection.localDescription });
            } catch (e) {
                console.error("Error establishing connection:", e);
            }
        });

        socket.on('candidate', (from, message) => {
            console.log("Received ICE candidate from broadcaster:", from, message);
            if (peerConnection) {
                peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate));
            }
        });
    </script>
</head>
<body>
    <h1>Watcher</h1>
    <video id="remoteVideo" autoplay controls></video>
</body>
</html>

问题根源与修复方案

1. 主播端核心问题:未监听观众连接事件,提前创建Offer

原主播端在获取媒体流后直接创建Offer并发送,但此时还没有观众请求连接,而且目标ID写死为'watcher_id',服务器无法正确转发。正确逻辑应该是:当服务器通知有观众连接(watcher事件)时,再创建PeerConnection并发送Offer给该观众。

2. ICE候选发送目标错误

  • 主播端发送ICE候选时,错误地将licenseID作为目标参数,服务器需要的是观众的socket ID
  • 观众端发送ICE候选时,写死目标为'broadcaster_id',应该用收到Offer时的主播socket ID(from参数)

修复后的主播端代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>主播端</title>
    <script src="/socket.io/socket.io.js"></script>
    <script>
        const socket = io.connect('http://localhost:4000');
        let localStream;
        const licenseID = 1; 
        // 存储每个观众对应的PeerConnection
        const peerConnections = new Map();

        const config = {
            iceServers: [
                { urls: 'stun:stun.l.google.com:19302' }
            ]
        };

        socket.emit('broadcaster', licenseID);

        // 获取本地媒体流
        navigator.mediaDevices.getUserMedia({ video: true, audio: true })
            .then(stream => {
                localStream = stream;
                const localVideo = document.getElementById('localVideo');
                localVideo.srcObject = stream;
            })
            .catch(error => {
                console.error('获取媒体设备失败: ', error);
            });

        // 监听服务器发来的观众连接请求
        socket.on('watcher', (watcherSocketId) => {
            if (!localStream) return;
            
            // 为该观众创建新的PeerConnection
            const peerConnection = new RTCPeerConnection(config);
            peerConnections.set(watcherSocketId, peerConnection);
            
            // 添加本地媒体轨道
            localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream));

            // 发送ICE候选给观众
            peerConnection.onicecandidate = (event) => {
                if (event.candidate) {
                    socket.emit('candidate', watcherSocketId, { candidate: event.candidate });
                }
            };

            // 创建并发送Offer给观众
            peerConnection.createOffer()
                .then(offer => peerConnection.setLocalDescription(offer))
                .then(() => {
                    socket.emit('offer', watcherSocketId, { description: peerConnection.localDescription });
                })
                .catch(error => {
                    console.error('创建Offer失败:', error);
                });

            // 处理观众发来的Answer
            socket.on('answer', (from, message) => {
                if (from === watcherSocketId) {
                    peerConnection.setRemoteDescription(new RTCSessionDescription(message.description))
                        .catch(error => {
                            console.error('设置远端描述失败:', error);
                        });
                }
            });

            // 处理观众发来的ICE候选
            socket.on('candidate', (from, message) => {
                if (from === watcherSocketId && peerConnection) {
                    peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate))
                        .catch(error => {
                            console.error('添加ICE候选失败:', error);
                        });
                }
            });
        });
    </script>
</head>
<body>
    <h1>主播端</h1>
    <video id="localVideo" autoplay muted></video>
</body>
</html>

修复后的观众端代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>观众端</title>
    <script src="/socket.io/socket.io.js"></script>
    <script>
        const socket = io.connect('http://localhost:4000');
        let peerConnection;
        const licenseID = 1; 

        const config = {
            iceServers: [
                { urls: 'stun:stun.l.google.com:19302' }
            ]
        };

        socket.emit('watcher', licenseID);

        // 处理主播发来的Offer
        socket.on('offer', async (broadcasterSocketId, message) => {
            console.log("收到主播Offer:", broadcasterSocketId, message);
            peerConnection = new RTCPeerConnection(config);

            // 接收主播的媒体流
            peerConnection.ontrack = (event) => {
                const remoteVideo = document.getElementById('remoteVideo');
                remoteVideo.srcObject = event.streams[0];
            };

            // 发送ICE候选给主播
            peerConnection.onicecandidate = (event) => {
                if (event.candidate) {
                    socket.emit('candidate', broadcasterSocketId, { candidate: event.candidate });
                }
            };

            try {
                await peerConnection.setRemoteDescription(new RTCSessionDescription(message.description));
                const answer = await peerConnection.createAnswer();
                await peerConnection.setLocalDescription(answer);

                // 发送Answer给主播
                socket.emit('answer', broadcasterSocketId, { description: peerConnection.localDescription });
            } catch (e) {
                console.error("建立连接失败:", e);
            }
        });

        // 处理主播发来的ICE候选
        socket.on('candidate', (from, message) => {
            console.log("收到主播ICE候选:", from, message);
            if (peerConnection) {
                peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate));
            }
        });
    </script>
</head>
<body>
    <h1>观众端</h1>
    <video id="remoteVideo" autoplay controls></video>
</body>
</html>

内容的提问来源于stack exchange,提问作者Good

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.20 19:39:53