WebRTC 直播模块实战02

WebRTC 直播模块实战

本篇目标

  1. 搭建信令服务器
  2. 写主播端 HTML:采集摄像头、建立连接、给每个观众发 Offer
  3. 跑起来验证:主播页面能把视频推给观众

1. 项目结构

webrtc-live/
  server.js
  public/
    publisher.html
    viewer.html
  package.json

2. 信令服务器(server.js)

功能:

  • 提供静态页面(express)
  • WebSocket 做信令转发
  • 支持房间:一个主播,多观众
  • 服务器不处理媒体数据,只转发消息

创建 server.js

const express = require("express");
const http = require("http");
const WebSocket = require("ws");
const path = require("path");

const app = express();
app.use(express.static(path.join(__dirname, "public")));

const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

// roomId -> { publisherId: string|null, viewers: Map<clientId, ws> }
const rooms = new Map();

function uid() {
  return Math.random().toString(16).slice(2) + Date.now().toString(16);
}

function send(ws, data) {
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data));
}

wss.on("connection", (ws) => {
  const clientId = uid();
  ws.clientId = clientId;
  ws.role = null;     // "publisher" | "viewer"
  ws.roomId = null;

  send(ws, { type: "welcome", clientId });

  ws.on("message", (raw) => {
    let msg;
    try { msg = JSON.parse(raw.toString()); } catch { return; }

    const { type, roomId, payload, targetId } = msg;

    if (type === "join") {
      ws.roomId = roomId;
      ws.role = msg.role;

      if (!rooms.has(roomId)) {
        rooms.set(roomId, { publisherId: null, publisherWs: null, viewers: new Map() });
      }

      const room = rooms.get(roomId);

      if (ws.role === "publisher") {
        room.publisherId = ws.clientId;
        room.publisherWs = ws;
        send(ws, { type: "joined", role: "publisher", roomId });
        // 通知主播:当前已有多少观众(可选)
        send(ws, { type: "room_state", viewers: Array.from(room.viewers.keys()) });
      } else {
        room.viewers.set(ws.clientId, ws);
        send(ws, { type: "joined", role: "viewer", roomId, publisherId: room.publisherId });

        // 通知主播:新观众加入
        if (room.publisherWs) {
          send(room.publisherWs, { type: "viewer_joined", viewerId: ws.clientId });
        }
      }
      return;
    }

    // 转发信令:viewer -> publisher 或 publisher -> viewer
    if (type === "signal") {
      const room = rooms.get(ws.roomId);
      if (!room) return;

      // targetId 必须指定
      if (!targetId) return;

      // 找到目标 websocket
      let targetWs = null;
      if (room.publisherId === targetId) targetWs = room.publisherWs;
      else targetWs = room.viewers.get(targetId);

      if (!targetWs) return;

      send(targetWs, {
        type: "signal",
        fromId: ws.clientId,
        payload
      });
      return;
    }
  });

  ws.on("close", () => {
    const { roomId, role, clientId } = ws;
    if (!roomId || !rooms.has(roomId)) return;

    const room = rooms.get(roomId);

    if (role === "publisher") {
      // 主播退出:通知所有观众
      for (const vws of room.viewers.values()) {
        send(vws, { type: "publisher_left" });
      }
      rooms.delete(roomId);
    } else {
      // 观众退出:从房间移除,并通知主播
      room.viewers.delete(clientId);
      if (room.publisherWs) {
        send(room.publisherWs, { type: "viewer_left", viewerId: clientId });
      }
    }
  });
});

server.listen(3000, () => {
  console.log("WebRTC Live Signaling Server running at http://localhost:3000");
});

3. 主播端:publisher.html(推流实现)

创建 public/publisher.html

<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8" />
  <title>WebRTC 直播 - 主播推流</title>
  <style>
    body { font-family: Arial; padding: 16px; }
    video { width: 480px; background: #000; border-radius: 8px; }
    .row { display: flex; gap: 16px; align-items: flex-start; }
    .panel { min-width: 320px; }
    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 class="row">
    <div>
      <video id="localVideo" autoplay playsinline muted></video>
      <div>
        <button id="btnStartCam">1) 打开摄像头/麦克风</button>
      </div>
      <div>
        房间号:<input id="roomId" value="room1" />
        <button id="btnJoin">2) 进入房间开始直播</button>
      </div>
    </div>

    <div class="panel">
      <div>已连接观众:<span id="viewerCount">0</span></div>
      <div class="log" id="log"></div>
    </div>
  </div>

<script>
  const logEl = document.getElementById("log");
  const localVideo = document.getElementById("localVideo");
  const viewerCountEl = document.getElementById("viewerCount");

  function log(...args) {
    logEl.textContent += args.join(" ") + "\n";
    logEl.scrollTop = logEl.scrollHeight;
  }

  let ws;
  let clientId;
  let roomId;
  let localStream = null;

  // viewerId -> RTCPeerConnection
  const pcs = new Map();

  // ICE 服务器:STUN 用 Google 公共服务(开发阶段可用)
  const rtcConfig = {
    iceServers: [
      { urls: "stun:stun.l.google.com:19302" }
    ]
  };

  async function openCamera() {
    if (localStream) return;
    localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
    localVideo.srcObject = localStream;
    log("[OK] 摄像头/麦克风已开启");
  }

  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") {
          log("[WS] joined as publisher, room =", msg.roomId);
          return;
        }

        if (msg.type === "room_state") {
          const viewers = msg.viewers || [];
          viewerCountEl.textContent = viewers.length;
          log("[ROOM] 当前观众列表:", viewers.join(", ") || "(none)");
          return;
        }

        if (msg.type === "viewer_joined") {
          const viewerId = msg.viewerId;
          log("[ROOM] 新观众加入:", viewerId);
          viewerCountEl.textContent = pcs.size + 1;

          // 为这个观众创建连接并发送 offer
          await createPeerForViewer(viewerId);
          return;
        }

        if (msg.type === "viewer_left") {
          const viewerId = msg.viewerId;
          log("[ROOM] 观众离开:", viewerId);
          closePeer(viewerId);
          viewerCountEl.textContent = pcs.size;
          return;
        }

        if (msg.type === "signal") {
          const fromId = msg.fromId;
          const payload = msg.payload;
          await handleSignal(fromId, payload);
          return;
        }
      };
    });
  }

  async function createPeerForViewer(viewerId) {
    if (!localStream) {
      log("[ERR] 还未打开摄像头,无法推流");
      return;
    }

    // 每个观众一条 PeerConnection
    const pc = new RTCPeerConnection(rtcConfig);
    pcs.set(viewerId, pc);

    // 把本地音视频轨道加进去(这一步就是“推流”)
    localStream.getTracks().forEach(track => {
      pc.addTrack(track, localStream);
    });

    // 收集本地 ICE candidate,发给对端
    pc.onicecandidate = (e) => {
      if (e.candidate) {
        ws.send(JSON.stringify({
          type: "signal",
          roomId,
          targetId: viewerId,
          payload: { kind: "ice", candidate: e.candidate }
        }));
      }
    };

    pc.onconnectionstatechange = () => {
      log(`[PC->${viewerId}] state =`, pc.connectionState);
    };

    // 创建 offer
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);

    // 发 offer 给观众
    ws.send(JSON.stringify({
      type: "signal",
      roomId,
      targetId: viewerId,
      payload: { kind: "offer", sdp: offer }
    }));

    log(`[PC->${viewerId}] offer sent`);
  }

  async function handleSignal(fromId, payload) {
    const pc = pcs.get(fromId);
    if (!pc) {
      log("[WARN] 收到信令但 pc 不存在 fromId =", fromId);
      return;
    }

    if (payload.kind === "answer") {
      await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp));
      log(`[PC->${fromId}] answer setRemoteDescription`);
      return;
    }

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

  function closePeer(viewerId) {
    const pc = pcs.get(viewerId);
    if (pc) {
      pc.close();
      pcs.delete(viewerId);
    }
  }

  async function joinAsPublisher() {
    roomId = document.getElementById("roomId").value.trim();
    if (!roomId) {
      log("[ERR] 房间号不能为空");
      return;
    }
    if (!localStream) {
      log("[ERR] 请先打开摄像头/麦克风");
      return;
    }

    await connectWS();
    ws.send(JSON.stringify({ type: "join", role: "publisher", roomId }));
    log("[OK] 已请求加入房间,等待观众进入...");
  }

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

4. 运行与验证

启动服务器:

node server.js

浏览器打开:

  • 主播端:http://localhost:3000/publisher.html

先点:

  1. 打开摄像头
  2. 进入房间开始直播

然后打开观众端(下一篇会实现):
http://localhost:3000/viewer.html


5. 本篇核心“原理点”你可以写进博客

  • 推流不是 RTMP,而是 addTrack() 把轨道交给 PeerConnection
  • Offer/Answer 是媒体能力协商(SDP)
  • ICE Candidate 交换是为了打洞穿透网络
posted @ 2025-11-06 08:10  元始天尊123  阅读(8)  评论(0)    收藏  举报