WebRTC 直播模块实战01

WebRTC 直播模块实战

本篇目标:

  1. 写观众端 viewer.html:加入房间、接收 offer、回传 answer
  2. 接收 ontrack 播放远端视频
  3. 解释多观众下为何主播要维护多个 PeerConnection
  4. 给你一套“常见失败排查清单”

1. 观众端:viewer.html

创建 public/viewer.html

<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8" />
  <title>WebRTC 直播 - 观众拉流</title>
  <style>
    body { font-family: Arial; padding: 16px; }
    video { width: 640px; background: #000; border-radius: 8px; }
    input, button { padding: 8px 10px; margin: 6px 0; }
    .log { white-space: pre-wrap; background:#f6f6f6; padding:10px; border-radius:8px; height:260px; overflow:auto; }
  </style>
</head>
<body>
  <h2>观众端(拉流)</h2>

  <div>
    房间号:<input id="roomId" value="room1" />
    <button id="btnJoin">加入房间观看</button>
  </div>

  <video id="remoteVideo" autoplay playsinline></video>
  <div class="log" id="log"></div>

<script>
  const remoteVideo = document.getElementById("remoteVideo");
  const logEl = document.getElementById("log");
  function log(...args) {
    logEl.textContent += args.join(" ") + "\n";
    logEl.scrollTop = logEl.scrollHeight;
  }

  let ws;
  let clientId;
  let roomId;
  let publisherId;

  let pc = null;

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

  function connectWS() {
    return new Promise((resolve, reject) => {
      ws = new WebSocket(`ws://${location.host}`);
      ws.onopen = () => resolve();
      ws.onerror = (e) => reject(e);

      ws.onmessage = async (evt) => {
        const msg = JSON.parse(evt.data);

        if (msg.type === "welcome") {
          clientId = msg.clientId;
          log("[WS] welcome clientId =", clientId);
          return;
        }

        if (msg.type === "joined") {
          publisherId = msg.publisherId;
          log("[WS] joined as viewer, room =", msg.roomId, "publisherId =", publisherId || "(none yet)");
          return;
        }

        if (msg.type === "publisher_left") {
          log("[ROOM] 主播已离开");
          cleanup();
          return;
        }

        if (msg.type === "signal") {
          const fromId = msg.fromId;     // 通常是 publisherId
          const payload = msg.payload;
          await handleSignal(fromId, payload);
          return;
        }
      };
    });
  }

  async function ensurePeerConnection() {
    if (pc) return pc;

    pc = new RTCPeerConnection(rtcConfig);

    // 收到远端媒体(这一步就是“拉流”)
    pc.ontrack = (e) => {
      // e.streams[0] 是包含音视频轨道的 MediaStream
      remoteVideo.srcObject = e.streams[0];
      log("[MEDIA] ontrack received, stream id =", e.streams[0].id);
    };

    pc.onicecandidate = (e) => {
      if (e.candidate) {
        ws.send(JSON.stringify({
          type: "signal",
          roomId,
          targetId: publisherId,
          payload: { kind: "ice", candidate: e.candidate }
        }));
      }
    };

    pc.onconnectionstatechange = () => {
      log("[PC] state =", pc.connectionState);
    };

    return pc;
  }

  async function handleSignal(fromId, payload) {
    // 第一次收到 offer 时,记住主播是谁
    if (!publisherId) publisherId = fromId;

    if (payload.kind === "offer") {
      log("[SIGNAL] offer received from", fromId);

      const pc = await ensurePeerConnection();

      await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp));
      log("[PC] setRemoteDescription(offer) OK");

      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);

      ws.send(JSON.stringify({
        type: "signal",
        roomId,
        targetId: fromId,
        payload: { kind: "answer", sdp: answer }
      }));

      log("[SIGNAL] answer sent");
      return;
    }

    if (payload.kind === "ice") {
      try {
        await ensurePeerConnection();
        await pc.addIceCandidate(new RTCIceCandidate(payload.candidate));
      } catch (e) {
        log("[PC] addIceCandidate error", e.message);
      }
      return;
    }
  }

  function cleanup() {
    if (pc) {
      pc.close();
      pc = null;
    }
    remoteVideo.srcObject = null;
  }

  async function joinAsViewer() {
    roomId = document.getElementById("roomId").value.trim();
    if (!roomId) {
      log("[ERR] 房间号不能为空");
      return;
    }
    await connectWS();
    ws.send(JSON.stringify({ type: "join", role: "viewer", roomId }));
    log("[OK] 已加入房间,等待主播 offer...");
  }

  document.getElementById("btnJoin").onclick = () => joinAsViewer().catch(e => log("[ERR]", e.message));
</script>
</body>
</html>

2. 为什么多观众下:主播必须开多个 PeerConnection?

因为在 P2P 架构中:

  • 每个观众都在不同网络环境
  • ICE 打洞路径不同
  • 所以主播需要:“每个观众一个连接”

这也是 P2P 直播的天然瓶颈:观众越多,主播上行压力越大。


3. 运行步骤

1)启动服务器

node server.js

2)打开主播端

  • http://localhost:3000/publisher.html
  • 点:打开摄像头 → 进入房间

3)打开观众端(可以开多个标签页模拟多人)

  • http://localhost:3000/viewer.html
  • 点:加入房间观看

4. 常见问题排查

4.1 摄像头打不开

  • 必须在 http://localhosthttps 下运行
  • 浏览器权限没开:地址栏小锁点开允许

4.2 观众一直黑屏、没有 ontrack

  • 检查主播是否真的 addTrack()
  • 看 console 是否有 SDP/ICE 报错
  • 打开 chrome://webrtc-internals/(Chrome 超强调试页)

4.3 本机 OK,局域网/跨网不通

  • 说明 NAT 穿透失败,需要 TURN
  • 企业网/对称 NAT 常见
  • 后续升级:加 TURN(coturn)或直接上 SFU

4.4 声音回声

  • 主播本地预览要 muted
  • 观众播放不要 muted(否则没声)
posted @ 2025-11-07 11:28  元始天尊123  阅读(3)  评论(0)    收藏  举报