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

WebRTC直播异常:观众无法观看主播流(附Go信令服务及客户端代码)

问题描述

基于WebRTC开发网页直播系统,采用Golang实现信令服务器连接主播与观众,支持5-10名主播同时开播。主播通过专属licenseID推流,观众通过指定licenseID观看对应主播的直播。系统运行后无报错,但观众端无法看到主播的直播画面。

服务端代码(Golang)

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "sync"

    "github.com/gorilla/websocket"
)

type Room3 struct {
    mu          sync.Mutex
    broadcaster *Client3
    watchers    map[*Client3]bool
}

type Client3 struct {
    socket        *websocket.Conn
    send          chan []byte
    licenseID     string
    isBroadcaster bool
}

var (
    upgrader3 = websocket.Upgrader{
        ReadBufferSize:  1024,
        WriteBufferSize: 1024,
        CheckOrigin: func(r *http.Request) bool {
            return true 
        },
    }
    mu     sync.Mutex
    rooms3 = make(map[string]*Room3)
)

func main() {
    http.HandleFunc("/ws", handleWebSocket)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func handleWebSocket(w http.ResponseWriter, r *http.Request) {
    socket, err := upgrader3.Upgrade(w, r, nil)
    if err != nil {
        log.Println("Upgrade error:", err)
        return
    }

    client := &Client3{
        socket: socket,
        send:   make(chan []byte),
    }

    go client.listen()
}

func (c *Client3) listen() {
    defer func() {
        c.socket.Close()
    }()

    for {
        _, msg, err := c.socket.ReadMessage()
        if err != nil {
            log.Println("Read error:", err)
            break
        }

        var message map[string]interface{}
        err = json.Unmarshal(msg, &message)
        if err != nil {
            log.Println("JSON unmarshal error:", err)
            continue
        }

        action, ok := message["action"].(string)
        if !ok {
            log.Println("Invalid action")
            continue
        }

        switch action {
        case "broadcaster":
            licenseID, ok := message["licenseID"].(string)
            if !ok {
                log.Println("Invalid licenseID for broadcaster")
                continue
            }
            handleBroadcaster(c, licenseID)
        case "viewer":
            licenseID, ok := message["licenseID"].(string)
            if !ok {
                log.Println("Invalid licenseID for viewer")
                continue
            }
            handleViewer(c, licenseID)
        case "offer", "answer", "candidate":
            licenseID, ok := message["licenseID"].(string)
            if !ok {
                log.Println("Invalid licenseID for WebRTC message")
                continue
            }
            handleWebRTCMessage(c, action, licenseID, msg)
        default:
            log.Println("Unknown action:", action)
        }
    }
}

func handleBroadcaster(client *Client3, licenseID string) {
    mu.Lock()
    defer mu.Unlock()

    if room, exists := rooms3[licenseID]; exists {
        if room.broadcaster != nil {
            log.Println("Broadcaster already exists for licenseID:", licenseID)
            return
        }

        client.licenseID = licenseID
        client.isBroadcaster = true
        room.broadcaster = client
        room.watchers = make(map[*Client3]bool)
        log.Printf("Broadcaster connected: LicenseID %s\n", licenseID)
    } else {
        room := &Room3{
            watchers: make(map[*Client3]bool),
        }
        client.licenseID = licenseID
        client.isBroadcaster = true
        room.broadcaster = client
        rooms3[licenseID] = room
        log.Printf("New room created with Broadcaster: LicenseID %s\n", licenseID)
    }
}

func handleViewer(client *Client3, licenseID string) {
    mu.Lock()
    defer mu.Unlock()

    if room, exists := rooms3[licenseID]; exists {
        if room.broadcaster == nil {
            log.Println("No broadcaster available for licenseID:", licenseID)
            client.socket.WriteJSON(map[string]string{"error": "No broadcaster available for this licenseID"})
            return
        }

        client.licenseID = licenseID
        room.watchers[client] = true
        // 修复:移除无法序列化的client对象,仅通知主播有新观众加入
        err := room.broadcaster.socket.WriteJSON(map[string]interface{}{
            "action":    "new_viewer",
            "licenseID": licenseID,
        })
        if err != nil {
            log.Println("Failed to notify broadcaster of new viewer:", err)
        }
        log.Printf("Viewer connected: LicenseID %s\n", licenseID)
    } else {
        log.Println("No room exists for licenseID:", licenseID)
        client.socket.WriteJSON(map[string]string{"error": "No room exists for this licenseID"})
    }
}

// 修复:添加发送者参数,区分消息来源是主播还是观众
func handleWebRTCMessage(sender *Client3, action, licenseID string, msg []byte) {
    mu.Lock()
    defer mu.Unlock()

    room, exists := rooms3[licenseID]
    if !exists {
        log.Println("Room3 does not exist for licenseID:", licenseID)
        return
    }

    switch action {
    case "offer":
        // 修复:主播的offer转发给所有观众,而非自己
        if sender.isBroadcaster {
            for watcher := range room.watchers {
                err := watcher.socket.WriteMessage(websocket.TextMessage, msg)
                if err != nil {
                    log.Println("Failed to send offer to viewer:", err)
                }
            }
        } else {
            log.Println("Only broadcaster can send offer")
        }
    case "answer":
        // 修复:观众的answer转发给主播,而非其他观众
        if !sender.isBroadcaster {
            if room.broadcaster != nil {
                err := room.broadcaster.socket.WriteMessage(websocket.TextMessage, msg)
                if err != nil {
                    log.Println("Failed to send answer to broadcaster:", err)
                }
            }
        } else {
            log.Println("Viewer should send answer")
        }
    case "candidate":
        // 修复:区分候选来源,主播的候选发给观众,观众的候选发给主播
        if sender.isBroadcaster {
            for watcher := range room.watchers {
                err := watcher.socket.WriteMessage(websocket.TextMessage, msg)
                if err != nil {
                    log.Println("Failed to send candidate to viewer:", err)
                }
            }
        } else {
            if room.broadcaster != nil {
                err := room.broadcaster.socket.WriteMessage(websocket.TextMessage, msg)
                if err != nil {
                    log.Println("Failed to send candidate to broadcaster:", err)
                }
            }
        }
    }
}

客户端代码

主播端(HTML/JS)

修复:支持多观众,为每个新观众创建独立的PeerConnection

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Broadcaster</title>
</head>
<body>
    <h1>Broadcaster</h1>
    <input type="text" id="licenseID" placeholder="Enter License ID">
    <button id="startBroadcasting">Start Broadcasting</button>
    <video id="localVideo" autoplay muted></video>

    <script>
        const ws = new WebSocket('ws://localhost:8080/ws');
        let localStream;
        const config = {
            iceServers: [
                { urls: 'stun:stun.l.google.com:19302' }
            ]
        };
        // 存储每个观众对应的PeerConnection
        const peerConnections = new Map();

        document.getElementById('startBroadcasting').onclick = async () => {
            const licenseID = document.getElementById('licenseID').value;
            if (!licenseID) {
                alert('Please enter a License ID');
                return;
            }

            ws.send(JSON.stringify({ action: 'broadcaster', licenseID }));

            localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
            document.getElementById('localVideo').srcObject = localStream;
        };

        ws.onmessage = async event => {
            const message = JSON.parse(event.data);
            const licenseID = message.licenseID;
            switch (message.action) {
                case 'new_viewer':
                    // 为新观众创建独立的PeerConnection
                    const peerConnection = new RTCPeerConnection(config);
                    peerConnections.set(licenseID + Date.now(), peerConnection);
                    
                    localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream));

                    peerConnection.onicecandidate = event => {
                        if (event.candidate) {
                            ws.send(JSON.stringify({ action: 'candidate', licenseID, candidate: event.candidate }));
                        }
                    };

                    // 创建并发送offer给观众
                    try {
                        await peerConnection.setLocalDescription(await peerConnection.createOffer());
                        ws.send(JSON.stringify({ action: 'offer', licenseID, description: peerConnection.localDescription }));
                    } catch (err) {
                        console.error('Error creating offer for new viewer:', err);
                    }
                    break;
                case 'answer':
                    // 为对应观众的PeerConnection设置远程描述
                    for (const [key, pc] of peerConnections.entries()) {
                        try {
                            await pc.setRemoteDescription(new RTCSessionDescription(message.description));
                            break;
                        } catch (err) {
                            console.error('Error setting remote description for answer:', err);
                        }
                    }
                    break;
                case 'candidate':
                    // 给所有PeerConnection添加ICE候选
                    peerConnections.forEach(pc => {
                        try {
                            pc.addIceCandidate(new RTCIceCandidate(message.candidate));
                        } catch (err) {
                            console.error('Error adding ICE candidate:', err);
                        }
                    });
                    break;
            }
        };
    </script>
</body>
</html>

观众端(HTML/JS)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Viewer</title>
</head>
<body>
    <h1>Viewer</h1>
    <input type="text" id="licenseID" placeholder="Enter License ID">
    <button id="startViewing">Start Viewing</button>
    <video id="remoteVideo" autoplay></video>

    <script>
        const ws = new WebSocket('ws://localhost:8080/ws');
        let peerConnection;
        let licenseID;
        const config = {
            iceServers: [
                { urls: 'stun:stun.l.google.com:19302' }
            ]
        };

        document.getElementById('startViewing').onclick = async () => {
            licenseID = document.getElementById('licenseID').value;
            if (!licenseID) {
                alert('Please enter a License ID');
                return;
            }

            ws.send(JSON.stringify({ action: 'viewer', licenseID }));
        };

        ws.onmessage = async event => {
            const message = JSON.parse(event.data);
            switch (message.action) {
                case 'offer':
                    try {
                        peerConnection = new RTCPeerConnection(config);
                        peerConnection.ontrack = event => {
                            document.getElementById('remoteVideo').srcObject = event.streams[0];
                        };

                        peerConnection.onicecandidate = event => {
                            if (event.candidate) {
                                ws.send(JSON.stringify({ action: 'candidate', licenseID: message.licenseID, candidate: event.candidate }));
                            }
                        };

                        await peerConnection.setRemoteDescription(new RTCSessionDescription(message.description));
                        const answer = await peerConnection.createAnswer();
                        await peerConnection.setLocalDescription(answer);
                        ws.send(JSON.stringify({ action: 'answer', licenseID: message.licenseID, description: answer }));
                    } catch (err) {
                        console.error('Error handling offer:', err);
                    }
                    break;
                case 'candidate':
                    try {
                        await peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate));
                    } catch (err) {
                        console.error('Error adding ICE candidate:', err);
                    }
                    break;
                case 'error':
                    alert(message.error);
                    break;
            }
        };
    </script>
</body>
</html>

关键问题说明及修复点

  1. 信令转发逻辑错误:

    • 原服务端将主播的offer回发给主播自己,观众无法收到开播信号,修改后改为转发给对应房间的所有观众。
    • 原服务端将观众的answer转发给其他观众,修改后改为定向转发给主播。
    • ICE候选转发区分来源:主播的网络候选发给观众,观众的候选发给主播,确保双方能建立网络连接。
  2. JSON序列化错误:

    • 原服务端通知主播有观众加入时,携带了无法序列化的client结构体对象,导致主播收不到新观众通知,修改后仅发送必要的动作标识和licenseID。
  3. 主播端PeerConnection管理:

    • 原主播端仅创建单个PeerConnection,无法同时连接多个观众,修改后为每个新观众创建独立的PeerConnection并统一管理。

内容的提问来源于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 21:03:08