1V1音视频对话2--Web 双浏览器完整通话测试(强制 relay)

这一步很重要,目标是两个浏览器窗口,通过你的 TURN 服务器,100% relay 建立视频通话,主要是测试跨网段的WebRTC实现。

第一部分:测试结构说明

我们做一个:

bash 复制代码
index.html
server.js (Node 信令)

流程:

浏览器 A ↔ WebSocket ↔ 浏览器 B

媒体流 100% 走 TURN relay

第二部分:先创建信令服务器(最简单)

在服务器执行:

bash 复制代码
apt install nodejs npm -y
mkdir webrtc-test
cd webrtc-test
npm init -y
npm install ws

创建文件:

bash 复制代码
vi server.js

粘贴:

bash 复制代码
const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 3000 });

let clients = {};

wss.on('connection', (ws) => {
    ws.on('message', (message) => {
        const data = JSON.parse(message);

        if (data.type === 'register') {
            clients[data.id] = ws;
            ws.id = data.id;
            console.log("Registered:", data.id);
        }

        if (data.to && clients[data.to]) {
            clients[data.to].send(JSON.stringify(data));
        }
    });

    ws.on('close', () => {
        if (ws.id) delete clients[ws.id];
    });
});

console.log("WebSocket signaling server running on port 3000");

启动:

bash 复制代码
node server.js

第三部分:创建 WebRTC 页面

创建:

bash 复制代码
vi index.html

粘贴完整代码:

bash 复制代码
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>WebRTC TURN Relay Test</title>
</head>
<body>

<h3>WebRTC TURN Relay Test</h3>

Your ID: <input id="myId"><br>
Peer ID: <input id="peerId"><br>

<button onclick="connect()">Connect</button>
<button onclick="call()">Call</button>
<button onclick="hangup()">Hangup</button>

<br><br>

<video id="localVideo" autoplay playsinline muted width="300"></video>
<video id="remoteVideo" autoplay playsinline width="300"></video>

<script>

let ws;
let pc;
let localStream;
let myId;
let peerId;
let pendingCandidates = [];

const iceConfig = {
  iceTransportPolicy: "relay",  // 强制走 TURN
  iceServers: [
    {
      urls: "turn:123.129.219.235:3478?transport=udp",
      username: "lanz",
      credential: "QAZ123"
    }
  ]
};

function logState() {
  if (!pc) return;
  console.log("ICE:", pc.iceConnectionState);
  console.log("Connection:", pc.connectionState);
}

function connect() {
  myId = document.getElementById("myId").value;

  ws = new WebSocket("wss://tt.toim.cc/ws");  // 修改成你的地址

  ws.onopen = () => {
    console.log("Connected to signaling server");
    ws.send(JSON.stringify({type:"register", id:myId}));
  };

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

    if (data.type === "offer") {
      peerId = data.from;
      await createPeer();

      await pc.setRemoteDescription(new RTCSessionDescription(data.offer));

      // 处理缓存的 candidates
      for (let c of pendingCandidates) {
        await pc.addIceCandidate(new RTCIceCandidate(c));
      }
      pendingCandidates = [];

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

      ws.send(JSON.stringify({
        type:"answer",
        answer,
        to:peerId,
        from:myId
      }));
    }

    if (data.type === "answer") {
      await pc.setRemoteDescription(new RTCSessionDescription(data.answer));

      for (let c of pendingCandidates) {
        await pc.addIceCandidate(new RTCIceCandidate(c));
      }
      pendingCandidates = [];
    }

    if (data.type === "candidate") {
      if (pc && pc.remoteDescription) {
        await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
      } else {
        pendingCandidates.push(data.candidate);
      }
    }

    if (data.type === "hangup") {
      cleanUp();
    }
  };
}

async function createPeer() {

  pc = new RTCPeerConnection(iceConfig);

  pc.onicecandidate = (event) => {
    if (event.candidate) {
      ws.send(JSON.stringify({
        type:"candidate",
        candidate:event.candidate,
        to:peerId,
        from:myId
      }));
    }
  };

  pc.ontrack = (event) => {
    console.log("Remote stream received");
    document.getElementById("remoteVideo").srcObject = event.streams[0];
  };

  pc.oniceconnectionstatechange = logState;
  pc.onconnectionstatechange = logState;

  try {
    localStream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });
  } catch (e) {
    console.warn("video+audio failed, fallback to audio only");
    localStream = await navigator.mediaDevices.getUserMedia({
      video: false,
      audio: true
    });
  }

  document.getElementById("localVideo").srcObject = localStream;

  localStream.getTracks().forEach(track => {
    pc.addTrack(track, localStream);
  });
}

async function call() {

  peerId = document.getElementById("peerId").value;

  await createPeer();

  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);

  ws.send(JSON.stringify({
    type:"offer",
    offer,
    to:peerId,
    from:myId
  }));
}

function hangup() {
  ws.send(JSON.stringify({
    type:"hangup",
    to:peerId,
    from:myId
  }));
  cleanUp();
}

function cleanUp() {
  if (pc) {
    pc.close();
    pc = null;
  }

  if (localStream) {
    localStream.getTracks().forEach(track => track.stop());
    localStream = null;
  }

  document.getElementById("localVideo").srcObject = null;
  document.getElementById("remoteVideo").srcObject = null;

  pendingCandidates = [];

  console.log("Call ended");
}

</script>

</body>
</html>

⚠ 把:

bash 复制代码
YOUR_SERVER_IP

替换成你的公网 IP,并记得打开3000端口!

第四步:测试方法

注意:这里index.html必须是localhost或可以访问的https地址,否则不够权限使用摄像头!

HTTPS 页面 + WS 信令要用 WSS

你如果把页面切到 https://,你代码里:

bash 复制代码
ws = new WebSocket("ws://YOUR_SERVER_IP:3000");

会被浏览器拦截(混合内容),必须改成:

bash 复制代码
ws = new WebSocket("wss://你的域名/ws");

在站点配置里加:

bash 复制代码
ws = new WebSocket("wss://你的域名/ws");

1️⃣ 浏览器1打开 index.html

2️⃣ 浏览器2打开 index.html

3️⃣ 分别输入 ID:

窗口1:

bash 复制代码
myId: A
peerId: B

窗口2:

bash 复制代码
myId: B
peerId: A
bash 复制代码
myId: B
peerId: A

4️⃣ 两边都点击 Connect

5️⃣ A 点击 Call

预期结果

本地视频显示

远端视频显示

控制台 ICE 显示 relay

跨网也可通

相关推荐
C澒1 小时前
以微前端为核心:SLDSMS 前端架构的演进之路与实践沉淀
前端·架构·系统架构·教育电商·交通物流
明月_清风1 小时前
OAuth2 与第三方登录的三个阶段(2010–至今)
前端·安全
We་ct1 小时前
LeetCode 138. 随机链表的复制:两种最优解法详解
前端·算法·leetcode·链表·typescript
dcmfxvr1 小时前
【无标题】
java·linux·前端
哈__2 小时前
基础入门 Flutter for OpenHarmony:video_player 视频播放组件详解
flutter·音视频
SoaringHeart2 小时前
Flutter 顶部滚动行为限制实现:NoTopOverScrollPhysics
前端·flutter
zhanglu51162 小时前
Java Lambda 表达式使用深度解析
开发语言·前端·python
全栈前端老曹2 小时前
【Redis】发布订阅模型 —— Pub/Sub 原理、消息队列、聊天系统实战
前端·数据库·redis·设计模式·node.js·全栈·发布订阅模型