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>
关键问题说明及修复点
信令转发逻辑错误:
- 原服务端将主播的
offer回发给主播自己,观众无法收到开播信号,修改后改为转发给对应房间的所有观众。 - 原服务端将观众的
answer转发给其他观众,修改后改为定向转发给主播。 - ICE候选转发区分来源:主播的网络候选发给观众,观众的候选发给主播,确保双方能建立网络连接。
- 原服务端将主播的
JSON序列化错误:
- 原服务端通知主播有观众加入时,携带了无法序列化的
client结构体对象,导致主播收不到新观众通知,修改后仅发送必要的动作标识和licenseID。
- 原服务端通知主播有观众加入时,携带了无法序列化的
主播端PeerConnection管理:
- 原主播端仅创建单个PeerConnection,无法同时连接多个观众,修改后为每个新观众创建独立的PeerConnection并统一管理。
内容的提问来源于stack exchange,提问作者Good
相关产品推荐
相关产品推荐

