在这篇文章中,我们将深入分析一个基于WebRTC技术实现的1对1视频聊天系统。该系统不仅支持实时视频通话,还集成了文本消息传输功能,是一个完整的P2P通信解决方案。 这里我的测试信令服务器和STUN服务器都是本地使用node启动的服务,想要服务器代码的小伙伴可以私聊。
技术架构概述
该系统基于以下核心技术构建:
- HTML5/CSS3:构建用户界面
- JavaScript:实现业务逻辑
- WebRTC:实现点对点音视频通信
- WebSocket:实现信令服务器通信
- STUN/TURN:实现NAT穿透
系统功能模块
1. 用户界面设计 系统采用响应式设计,支持在桌面和移动设备上使用。主要界面包含以下区域:
<div class="video-wrapper">
<video id="local-video" autoplay muted playsinline></video>
<div class="video-label">本地视频</div>
</div>
<div class="video-wrapper">
<video id="remote-video" autoplay playsinline></video>
<div class="video-label">远程视频</div>
</div>
视频区域采用Flex布局,支持自适应屏幕大小。通过playsinline属性确保在移动设备上正常播放。
2. 连接管理模块 连接管理是WebRTC应用的核心,系统实现了完整的连接建立流程:
2.1 信令服务器连接
function initSignaling() {
try {
// 使用公共测试信令服务器
signalingSocket = new WebSocket('ws://localhost:3000');
signalingSocket.onopen = () => {
signalingStatus.innerHTML = '<i class="fas fa-server"></i> 信令服务器状态: 已连接';
addDebugMessage('信令服务器连接成功');
updateStep(1);
// 注册客户端
signalingSocket.send(JSON.stringify({
type: 'register',
id: localId
}));
};
signalingSocket.onmessage = (event) => {
let message;
if (event.data instanceof Blob) {
// 处理Blob数据
event.data.text().then(text => {
message = JSON.parse(text);
addDebugMessage(`收到信令消息: ${message && message.type}`);
handleSignalingMessage(message);
});
addDebugMessage('收到二进制格式的信令消息');
} else {
message = JSON.parse(event.data);
addDebugMessage(`收到信令消息: ${message && message.type}`);
handleSignalingMessage(message);
}
};
signalingSocket.onerror = (error) => {
addDebugMessage(`信令服务器错误: ${error.message}`);
signalingStatus.innerHTML = '<i class="fas fa-server"></i> 信令服务器状态: 连接错误';
};
signalingSocket.onclose = () => {
addDebugMessage('信令服务器连接已关闭');
signalingStatus.innerHTML = '<i class="fas fa-server"></i> 信令服务器状态: 已断开';
};
} catch (error) {
addDebugMessage(`信令连接失败: ${error.message}`);
signalingStatus.innerHTML = '<i class="fas fa-server"></i> 信令服务器状态: 连接失败';
}
}
信令服务器用于交换连接信息,包括SDP描述和ICE候选。
2.2 WebRTC连接初始化
// 初始化WebRTC连接
async function initConnection() {
try {
addDebugMessage('正在初始化WebRTC连接...');
// 创建配置 - 使用公共STUN服务器
const config = {
iceServers: [
// 国内可尝试的 (测试用,不稳定!)
{ urls: "stun:localhost:3478" },
{ urls: "stun:stun.voipstunt.com:3478" },
{ urls: "stun:stun.ekiga.net:3478" },
{ urls: "stun:stun.voxgratia.org:3478" },
{ urls: "stun:stun.internetcalls.com:3478" },
{ urls: "stun:stun.voip.aebc.com:3478" },
{ urls: "stun:stun.internetcalls.com:3478" },
{ urls: "stun:stun.miwifi.com:3478" },
{ urls: "stun:stun.qq.com:3478" },
// 全球知名的 (非国内,延迟可能高)
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
{ urls: "stun:stun2.l.google.com:19302" },
{
urls: "turn:freeturn.net:80",
username: "free",
credential: "free"
},
{
urls: "turn:freeturn.tel:80",
username: "free",
credential: "free"
},
]
};
// 创建对等连接
peerConnection = new RTCPeerConnection(config);
// 添加本地媒体流到连接
// if (localStream) {
// localStream.getTracks().forEach(track => {
// peerConnection.addTrack(track, localStream);
// });
// }
// 添加本地媒体流到连接(避免重复添加)
if (localStream) {
localStream.getTracks().forEach(track => {
// 检查是否已经存在该轨道的发送器
const existingSender = peerConnection.getSenders().find(sender =>
sender.track && sender.track.kind === track.kind);
// 如果不存在相同类型的发送器,则添加新轨道
if (!existingSender) {
peerConnection.addTrack(track, localStream);
addDebugMessage(`添加媒体轨道: ${track.kind}`);
}
});
}
// 设置远程流接收
peerConnection.ontrack = (event) => {
addDebugMessage('收到远程媒体流');
if (!remoteStream) {
remoteStream = new MediaStream();
remoteVideo.srcObject = remoteStream;
}
remoteStream.addTrack(event.track);
};
// 设置ICE候选处理
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
signalingSocket.send(JSON.stringify({
type: 'candidate',
from: localId,
to: remoteId,
candidate: event.candidate
}));
addDebugMessage('已发送ICE候选');
} else {
addDebugMessage('ICE候选收集完成');
}
};
// 设置ICE连接状态变化
peerConnection.oniceconnectionstatechange = () => {
addDebugMessage(`ICE连接状态: ${peerConnection.iceConnectionState}`);
if (peerConnection.iceConnectionState === 'connected') {
updateStep(4);
}
};
// 如果是发起方,创建数据通道
if (!dataChannel) {
dataChannel = peerConnection.createDataChannel("messaging", {
ordered: true
});
setupDataChannelEvents();
}
// 设置远程数据通道事件
peerConnection.ondatachannel = (event) => {
addDebugMessage('收到远程数据通道');
dataChannel = event.channel;
setupDataChannelEvents();
};
// 创建包含媒体信息的OFFER
const offer = await peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
await peerConnection.setLocalDescription(offer);
addDebugMessage('已创建OFFER');
// 发送OFFER
signalingSocket.send(JSON.stringify({
type: 'offer',
from: localId,
to: remoteId,
sdp: offer.sdp
}));
addDebugMessage('已发送OFFER');
updateStep(2);
// 更新状态
connectionStatus.textContent = "连接中...";
connectionStatus.style.color = "#ffcc00";
} catch (error) {
addDebugMessage(`初始化连接失败: ${error.message}`);
connectionStatus.textContent = "连接失败";
connectionStatus.style.color = "#ff6b6b";
}
}
3. 音视频流处理
系统支持音视频采集和传输:
3.1 获取本地媒体流
async function startLocalVideo() {
try {
const constraints = {
video: {
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: true
};
localStream = await navigator.mediaDevices.getUserMedia(constraints);
localVideo.srcObject = localStream;
addDebugMessage('本地视频流已启动');
startVideoBtn.disabled = true;
stopVideoBtn.disabled = false;
// 如果已经建立了连接,需要重新协商
if (peerConnection && peerConnection.connectionState === 'connected') {
signalingSocket.send(JSON.stringify({
type: 'mediaStatus',
from: localId,
to: remoteId,
status: 'started'
}));
await renegotiateConnection();
}
} catch (error) {
addDebugMessage(`获取本地媒体流失败: ${error.message}`);
addMessage('系统', `无法访问摄像头或麦克风: ${error.message}`, 'remote');
}
}
3.2 媒体流重新协商
当媒体状态发生变化时,系统支持动态重新协商:
async function renegotiateConnection() {
try {
if (!peerConnection || !localStream) return;
// 检查并添加本地媒体流到连接(避免重复添加)
let tracksAdded = false;
localStream.getTracks().forEach(track => {
// 检查是否已经存在该轨道的发送器
const existingSender = peerConnection.getSenders().find(sender =>
sender.track && sender.track.kind === track.kind);
// 如果不存在相同类型的发送器,则添加新轨道
if (!existingSender) {
peerConnection.addTrack(track, localStream);
addDebugMessage(`添加媒体轨道: ${track.kind}`);
tracksAdded = true;
}
});
// 如果添加了新轨道,则触发重新协商
if (tracksAdded) {
// 创建新的offer
const offer = await peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
await peerConnection.setLocalDescription(offer);
// 发送媒体协商offer
signalingSocket.send(JSON.stringify({
type: 'mediaOffer',
from: localId,
to: remoteId,
sdp: offer.sdp
}));
addDebugMessage('已发送媒体协商OFFER');
}
} catch (error) {
addDebugMessage(`重新协商失败: ${error.message}`);
}
}
4. 数据通道通信
除了音视频通信,系统还支持通过RTCDataChannel进行文本消息传输:
// 如果是发起方,创建数据通道
if (!dataChannel) {
dataChannel = peerConnection.createDataChannel("messaging", {
ordered: true
});
setupDataChannelEvents();
}
// 设置远程数据通道事件
peerConnection.ondatachannel = (event) => {
addDebugMessage('收到远程数据通道');
dataChannel = event.channel;
setupDataChannelEvents();
};
function setupDataChannelEvents() {
if (!dataChannel) return;
// ICE状态跟踪
peerConnection.oniceconnectionstatechange = () => {
const state = peerConnection.iceConnectionState;
addDebugMessage(`ICE连接状态变化: ${state}`);
switch(state) {
case 'checking':
addDebugMessage('正在进行ICE连接检查...');
break;
case 'connected':
addDebugMessage('ICE连接已建立');
updateStep(4);
// 确保连接状态更新
connectionStatus.textContent = "已连接";
connectionStatus.style.color = "#00ff80";
connectBtn.disabled = true;
disconnectBtn.disabled = false;
messageInput.disabled = false;
sendBtn.disabled = false;
connectionTypeSpan.textContent = "P2P直连";
break;
case 'completed':
addDebugMessage('ICE连接完成');
break;
case 'disconnected':
addDebugMessage('ICE连接断开,尝试重新连接...');
// 不立即重连,等待一段时间看是否能自动恢复
setTimeout(() => {
if (peerConnection && peerConnection.iceConnectionState === 'disconnected') {
addDebugMessage('ICE连接仍未恢复,尝试重启');
// 尝试收集新的候选
peerConnection.restartIce();
}
}, 3000);
break;
case 'failed':
addDebugMessage('ICE连接失败,尝试重启...');
peerConnection.restartIce();
break;
case 'closed':
addDebugMessage('ICE连接已关闭');
break;
}
};
dataChannel.onopen = () => {
addDebugMessage('数据通道已打开');
connectionStatus.textContent = "已连接";
connectionStatus.style.color = "#00ff80";
connectBtn.disabled = true;
disconnectBtn.disabled = false;
messageInput.disabled = false;
sendBtn.disabled = false;
connectionTypeSpan.textContent = "P2P直连";
// 添加欢迎消息
addMessage('系统', 'WebRTC点对点连接已建立!现在可以发送消息了', 'remote');
};
dataChannel.onclose = () => {
setTimeout(() => {
addDebugMessage('尝试重新连接...');
initConnection();
}, 2000);
};
dataChannel.onerror = (error) => {
addDebugMessage(`数据通道错误: ${error.message}`);
};
dataChannel.onmessage = (event) => {
addDebugMessage(`收到消息: ${event.data}`);
messagesReceived++;
messagesReceivedSpan.textContent = messagesReceived;
addMessage('远程端', event.data, 'remote');
};
}
5. ICE连接状态管理
系统实现了完整的ICE连接状态跟踪:
// ICE状态跟踪
peerConnection.oniceconnectionstatechange = () => {
const state = peerConnection.iceConnectionState;
addDebugMessage(`ICE连接状态变化: ${state}`);
switch(state) {
case 'checking':
addDebugMessage('正在进行ICE连接检查...');
break;
case 'connected':
addDebugMessage('ICE连接已建立');
updateStep(4);
// 确保连接状态更新
connectionStatus.textContent = "已连接";
connectionStatus.style.color = "#00ff80";
connectBtn.disabled = true;
disconnectBtn.disabled = false;
messageInput.disabled = false;
sendBtn.disabled = false;
connectionTypeSpan.textContent = "P2P直连";
break;
case 'completed':
addDebugMessage('ICE连接完成');
break;
case 'disconnected':
addDebugMessage('ICE连接断开,尝试重新连接...');
// 不立即重连,等待一段时间看是否能自动恢复
setTimeout(() => {
if (peerConnection && peerConnection.iceConnectionState === 'disconnected') {
addDebugMessage('ICE连接仍未恢复,尝试重启');
// 尝试收集新的候选
peerConnection.restartIce();
}
}, 3000);
break;
case 'failed':
addDebugMessage('ICE连接失败,尝试重启...');
peerConnection.restartIce();
break;
case 'closed':
addDebugMessage('ICE连接已关闭');
break;
}
};
技术亮点
1. 完整的连接状态跟踪
系统通过可视化步骤指示器展示连接建立过程:
<div class="connection-diagram">
<div class="connection-steps">
<div class="step" id="step1">
<div class="step-number">1</div>
<div class="step-label">信令连接</div>
</div>
<div class="step" id="step2">
<div class="step-number">2</div>
<div class="step-label">交换SDP</div>
</div>
<div class="step" id="step3">
<div class="step-number">3</div>
<div class="step-label">ICE穿透</div>
</div>
<div class="step" id="step4">
<div class="step-number">4</div>
<div class="step-label">连接建立</div>
</div>
</div>
</div>
2. 动态媒体协商
系统支持在连接建立后动态添加或移除媒体轨道,实现灵活的媒体控制。
3. 完善的错误处理和调试机制
通过详细的调试信息输出和错误处理机制,帮助开发者快速定位和解决问题。
完整代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebRTC消息发送系统</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background: linear-gradient(135deg, #1a2980, #26d0ce);
color: #fff;
min-height: 100vh;
padding: 20px;
overflow-x: hidden;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 768px) {
.container {
grid-template-columns: 1fr;
}
}
header {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: rgba(0, 0, 0, 0.3);
border-radius: 15px;
grid-column: 1 / -1;
}
h1 {
font-size: 2.8rem;
margin-bottom: 10px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
}
.subtitle {
font-size: 1.2rem;
opacity: 0.9;
max-width: 800px;
margin: 0 auto;
}
.card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: 25px;
margin-bottom: 25px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.18);
height: fit-content;
}
.card-title {
font-size: 1.5rem;
margin-bottom: 20px;
display: flex;
align-items: center;
color: #00f2fe;
}
.card-title i {
margin-right: 10px;
font-size: 1.8rem;
}
.connection-info {
display: flex;
flex-direction: column;
gap: 15px;
margin-bottom: 20px;
}
.info-item {
background: rgba(0, 0, 0, 0.2);
padding: 12px;
border-radius: 8px;
display: flex;
align-items: center;
}
.info-item i {
font-size: 1.2rem;
margin-right: 10px;
width: 30px;
text-align: center;
}
.controls {
display: flex;
flex-direction: column;
gap: 15px;
margin-bottom: 20px;
}
input, select {
width: 100%;
padding: 12px 15px;
border-radius: 8px;
border: none;
background: rgba(0, 0, 0, 0.3);
color: white;
font-size: 1rem;
}
input::placeholder {
color: rgba(255, 255, 255, 0.6);
}
button {
background: linear-gradient(to right, #4facfe, #00f2fe);
color: white;
border: none;
padding: 15px 20px;
font-size: 1.1rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
button:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
button:active {
transform: translateY(1px);
}
.btn-disconnect {
background: linear-gradient(to right, #ff416c, #ff4b2b);
}
.btn-send {
background: linear-gradient(to right, #00b09b, #96c93d);
width: 100%;
margin-top: 10px;
}
.chat-container {
display: flex;
flex-direction: column;
height: 400px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 15px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
margin-bottom: 15px;
display: flex;
flex-direction: column;
gap: 10px;
}
.message {
padding: 10px 15px;
border-radius: 18px;
max-width: 80%;
word-wrap: break-word;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.message.local {
background: linear-gradient(135deg, #4facfe, #00f2fe);
align-self: flex-end;
border-bottom-right-radius: 5px;
}
.message.remote {
background: rgba(255, 255, 255, 0.15);
align-self: flex-start;
border-bottom-left-radius: 5px;
}
.message-info {
font-size: 0.8rem;
opacity: 0.7;
margin-top: 5px;
}
.message-input {
display: flex;
gap: 10px;
}
.message-input input {
flex: 1;
}
.status {
padding: 15px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
text-align: center;
font-weight: 500;
min-height: 54px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 15px;
}
.status.connected {
background: rgba(0, 255, 128, 0.2);
color: #00ff80;
}
.status.disconnected {
background: rgba(255, 0, 0, 0.2);
color: #ff6b6b;
}
.instructions {
line-height: 1.6;
margin-top: 20px;
}
.instructions ul {
padding-left: 25px;
margin: 15px 0;
}
.instructions li {
margin-bottom: 8px;
font-size: 0.95rem;
}
.footer {
text-align: center;
margin-top: 30px;
padding: 20px;
font-size: 0.9rem;
opacity: 0.8;
grid-column: 1 / -1;
}
.code-block {
background: rgba(0, 0, 0, 0.3);
padding: 15px;
border-radius: 8px;
margin: 15px 0;
font-family: monospace;
font-size: 0.9rem;
overflow-x: auto;
}
.peer-connection {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
margin-bottom: 20px;
}
.peer {
background: rgba(0, 0, 0, 0.2);
padding: 20px;
border-radius: 50%;
width: 100px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
position: relative;
}
.peer:before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 120%;
height: 120%;
border: 2px dashed rgba(255, 255, 255, 0.3);
border-radius: 50%;
transform: translate(-50%, -50%);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 0.5; }
50% { opacity: 0.2; }
100% { opacity: 0.5; }
}
.peer i {
font-size: 2.5rem;
margin-bottom: 10px;
}
.peer-connection-line {
flex: 1;
height: 4px;
background: linear-gradient(to right, #4facfe, #00f2fe);
position: relative;
overflow: hidden;
}
.peer-connection-line:after {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.5), transparent);
animation: flow 1.5s infinite;
}
@keyframes flow {
100% { left: 100%; }
}
.connection-stats {
display: flex;
justify-content: space-around;
margin-top: 10px;
}
.stat-item {
text-align: center;
}
.stat-value {
font-weight: bold;
font-size: 1.2rem;
color: #00f2fe;
}
.stat-label {
font-size: 0.8rem;
opacity: 0.8;
}
.debug-console {
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 15px;
margin-top: 20px;
max-height: 200px;
overflow-y: auto;
font-family: monospace;
font-size: 0.85rem;
}
.debug-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.debug-message {
padding: 5px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.debug-message:last-child {
border-bottom: none;
}
.connection-diagram {
display: flex;
flex-direction: column;
align-items: center;
margin: 20px 0;
}
.connection-steps {
display: flex;
justify-content: space-around;
width: 100%;
margin: 15px 0;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 80px;
}
.step-number {
width: 30px;
height: 30px;
background: #4facfe;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
font-weight: bold;
}
.step.active .step-number {
background: #00f2fe;
box-shadow: 0 0 10px rgba(0, 242, 254, 0.5);
}
.step-label {
font-size: 0.8rem;
}
.troubleshooting {
background: rgba(255, 100, 100, 0.15);
border-left: 4px solid #ff6b6b;
padding: 15px;
border-radius: 0 8px 8px 0;
margin-top: 20px;
}
.troubleshooting h3 {
color: #ff6b6b;
margin-bottom: 10px;
}
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 15px;
}
.qr-code {
width: 120px;
height: 120px;
background: #fff;
padding: 10px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
}
.qr-code span {
font-size: 0.7rem;
text-align: center;
color: #000;
}
.video-container {
display: flex;
justify-content: space-around;
margin: 20px 0;
grid-column: 1 / -1;
gap: 20px;
}
@media (max-width: 768px) {
.video-container {
flex-direction: column;
}
}
.video-wrapper {
position: relative;
flex: 1;
max-width: 45%;
}
@media (max-width: 768px) {
.video-wrapper {
max-width: 100%;
}
}
video {
width: 100%;
height: 200px;
background: #000;
border-radius: 10px;
object-fit: cover;
}
.video-label {
position: absolute;
bottom: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.5);
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 0.8rem;
}
</style>
</head>
<body>
<div class="video-container">
<div class="video-wrapper">
<video id="local-video" autoplay muted playsinline></video>
<div class="video-label">本地视频</div>
</div>
<div class="video-wrapper">
<video id="remote-video" autoplay playsinline></video>
<div class="video-label">远程视频</div>
</div>
</div>
<div class="container">
<header>
<h1><i class="fas fa-satellite-dish"></i> WebRTC消息发送系统</h1>
<p class="subtitle">点对点实时通信实现 - 修复连接问题版本</p>
</header>
<div class="card">
<h2 class="card-title"><i class="fas fa-plug"></i> 连接设置</h2>
<div class="peer-connection">
<div class="peer">
<i class="fas fa-user"></i>
<div>本机</div>
</div>
<div class="peer-connection-line"></div>
<div class="peer">
<i class="fas fa-user-friends"></i>
<div>对等端</div>
</div>
</div>
<div class="connection-diagram">
<div class="connection-steps">
<div class="step" id="step1">
<div class="step-number">1</div>
<div class="step-label">信令连接</div>
</div>
<div class="step" id="step2">
<div class="step-number">2</div>
<div class="step-label">交换SDP</div>
</div>
<div class="step" id="step3">
<div class="step-number">3</div>
<div class="step-label">ICE穿透</div>
</div>
<div class="step" id="step4">
<div class="step-number">4</div>
<div class="step-label">连接建立</div>
</div>
</div>
</div>
<div class="connection-info">
<div class="info-item">
<i class="fas fa-signal"></i>
<div>连接状态: <span id="connection-status">未连接</span></div>
</div>
<div class="info-item">
<i class="fas fa-id-badge"></i>
<div>本地ID: <span id="local-id">生成中...</span></div>
</div>
</div>
<div class="controls">
<div class="info-item">
<i class="fas fa-user-friends"></i>
<input type="text" id="remote-id" placeholder="输入对等端ID">
</div>
<button id="connect-btn">
<i class="fas fa-link"></i> 连接到对等端
</button>
<button id="start-video-btn">
<i class="fas fa-video"></i> 开启视频
</button>
<button id="stop-video-btn" disabled>
<i class="fas fa-video-slash"></i> 关闭视频
</button>
<button id="disconnect-btn" class="btn-disconnect" disabled>
<i class="fas fa-unlink"></i> 断开连接
</button>
</div>
<div class="qr-container">
<div class="qr-code">
<span>扫描二维码<br>与同一设备<br>测试连接</span>
</div>
<div>在同一设备上打开两个标签页测试</div>
</div>
<div class="status" id="signaling-status">
<i class="fas fa-server"></i> 信令服务器状态: 正在连接...
</div>
<div class="troubleshooting">
<h3><i class="fas fa-tools"></i> 连接问题排查</h3>
<ul>
<li>确保两个客户端都连接到同一个信令服务器</li>
<li>检查防火墙是否允许WebSocket连接</li>
<li>尝试使用公共STUN服务器:stun.l.google.com:19302</li>
<li>在同一设备打开两个标签页测试连接</li>
</ul>
</div>
</div>
<div class="card">
<h2 class="card-title"><i class="fas fa-comments"></i> 消息发送</h2>
<div class="chat-container">
<div class="chat-messages" id="chat-messages">
<div class="message remote">
<div>欢迎使用WebRTC消息系统!连接对等端后即可开始聊天</div>
<div class="message-info">系统消息</div>
</div>
<div class="message remote">
<div>已修复连接问题,现在可以稳定建立P2P连接</div>
<div class="message-info">系统消息</div>
</div>
</div>
<div class="message-input">
<input type="text" id="message-input" placeholder="输入消息..." disabled>
<button id="send-btn" class="btn-send" disabled>
<i class="fas fa-paper-plane"></i> 发送
</button>
</div>
</div>
<div class="connection-stats">
<div class="stat-item">
<div class="stat-value" id="messages-sent">0</div>
<div class="stat-label">已发送</div>
</div>
<div class="stat-item">
<div class="stat-value" id="messages-received">0</div>
<div class="stat-label">已接收</div>
</div>
<div class="stat-item">
<div class="stat-value" id="connection-type">-</div>
<div class="stat-label">连接类型</div>
</div>
</div>
<div class="debug-console">
<div class="debug-title">
<div>连接调试信息</div>
<button id="clear-debug" style="padding: 5px 10px; font-size: 0.8rem;">清除</button>
</div>
<div id="debug-output"></div>
</div>
</div>
<div class="card">
<h2 class="card-title"><i class="fas fa-code"></i> WebRTC数据通道</h2>
<div class="instructions">
<p>WebRTC使用<strong>RTCPeerConnection</strong>建立点对点连接,通过<strong>RTCDataChannel</strong>发送消息:</p>
<div class="code-block">
// 创建对等连接<br>
const peerConnection = new RTCPeerConnection({<br>
iceServers: [<br>
{ urls: "stun:stun.l.google.com:19302" },<br>
{ urls: "stun:stun1.l.google.com:19302" }<br>
]<br>
});<br><br>
// 创建数据通道<br>
const dataChannel = peerConnection.createDataChannel('messaging');<br><br>
// 发送消息<br>
function sendMessage(message) {<br>
if (dataChannel.readyState === 'open') {<br>
dataChannel.send(message);<br>
}<br>
}<br><br>
// 接收消息<br>
peerConnection.ondatachannel = (event) => {<br>
const channel = event.channel;<br>
channel.onmessage = (event) => {<br>
console.log('收到消息:', event.data);<br>
};<br>
};
</div>
<h3>修复的连接问题:</h3>
<ul>
<li>使用公共STUN服务器解决NAT穿透问题</li>
<li>优化信令交换流程</li>
<li>添加详细的连接状态跟踪</li>
<li>改进错误处理和重连机制</li>
</ul>
</div>
</div>
<div class="card">
<h2 class="card-title"><i class="fas fa-info-circle"></i> 连接建立流程</h2>
<div class="instructions">
<h3>修复后的连接过程:</h3>
<ol>
<li><strong>信令连接</strong>:连接到WebSocket信令服务器</li>
<li><strong>生成ID</strong>:创建唯一的客户端标识符</li>
<li><strong>交换SDP</strong>:通过信令服务器交换会话描述</li>
<li><strong>ICE穿透</strong>:使用STUN服务器获取公网地址</li>
<li><strong>建立连接</strong>:完成P2P连接建立</li>
<li><strong>数据通道</strong>:打开RTCDataChannel进行通信</li>
</ol>
<h3>使用说明:</h3>
<ol>
<li>打开两个浏览器标签页(或两台设备)</li>
<li>复制第一个标签页的本地ID到第二个标签页的"对等端ID"字段</li>
<li>在第二个标签页点击"连接到对等端"</li>
<li>连接建立后即可发送消息</li>
</ol>
<div class="status connected">
<i class="fas fa-shield-alt"></i> 所有消息均使用端到端加密(DTLS),确保通信安全
</div>
</div>
</div>
<footer class="footer">
<p>© 2023 WebRTC消息系统 | 修复连接问题版本 | 使用公共STUN服务器</p>
</footer>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 页面元素
const localIdSpan = document.getElementById('local-id');
const remoteIdInput = document.getElementById('remote-id');
const connectBtn = document.getElementById('connect-btn');
const disconnectBtn = document.getElementById('disconnect-btn');
const sendBtn = document.getElementById('send-btn');
const messageInput = document.getElementById('message-input');
const chatMessages = document.getElementById('chat-messages');
const connectionStatus = document.getElementById('connection-status');
const signalingStatus = document.getElementById('signaling-status');
const messagesSentSpan = document.getElementById('messages-sent');
const messagesReceivedSpan = document.getElementById('messages-received');
const connectionTypeSpan = document.getElementById('connection-type');
const debugOutput = document.getElementById('debug-output');
const clearDebugBtn = document.getElementById('clear-debug');
let localStream = null;
let remoteStream = null;
const localVideo = document.getElementById('local-video');
const remoteVideo = document.getElementById('remote-video');
const startVideoBtn = document.getElementById('start-video-btn');
const stopVideoBtn = document.getElementById('stop-video-btn');
// 步骤指示器
const step1 = document.getElementById('step1');
const step2 = document.getElementById('step2');
const step3 = document.getElementById('step3');
const step4 = document.getElementById('step4');
// 重置步骤指示器
function resetSteps() {
step1.classList.remove('active');
step2.classList.remove('active');
step3.classList.remove('active');
step4.classList.remove('active');
}
// 更新步骤指示器
function updateStep(stepNumber) {
resetSteps();
if (stepNumber >= 1) step1.classList.add('active');
if (stepNumber >= 2) step2.classList.add('active');
if (stepNumber >= 3) step3.classList.add('active');
if (stepNumber >= 4) step4.classList.add('active');
}
// 添加调试信息
function addDebugMessage(message) {
const messageElement = document.createElement('div');
messageElement.classList.add('debug-message');
messageElement.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
debugOutput.appendChild(messageElement);
debugOutput.scrollTop = debugOutput.scrollHeight;
}
// 状态变量
let localId = '';
let remoteId = '';
let peerConnection = null;
let dataChannel = null;
let messagesSent = 0;
let messagesReceived = 0;
let signalingSocket = null;
// 生成随机ID
function generateId() {
return Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 10);
}
// 初始化本地ID
function initLocalId() {
localId = generateId();
localIdSpan.textContent = localId;
addDebugMessage(`本地ID已生成: ${localId}`);
}
// 添加获取本地媒体流的函数
async function startLocalVideo() {
try {
const constraints = {
video: {
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: true
};
localStream = await navigator.mediaDevices.getUserMedia(constraints);
localVideo.srcObject = localStream;
addDebugMessage('本地视频流已启动');
startVideoBtn.disabled = true;
stopVideoBtn.disabled = false;
// 如果已经建立了连接,需要重新协商
if (peerConnection && peerConnection.connectionState === 'connected') {
signalingSocket.send(JSON.stringify({
type: 'mediaStatus',
from: localId,
to: remoteId,
status: 'started'
}));
await renegotiateConnection();
}
} catch (error) {
addDebugMessage(`获取本地媒体流失败: ${error.message}`);
addMessage('系统', `无法访问摄像头或麦克风: ${error.message}`, 'remote');
}
}
// 添加停止本地视频的函数
function stopLocalVideo() {
if (localStream) {
// 如果存在对等连接,需要移除发送器
if (peerConnection && peerConnection.signalingState !== 'closed') {
signalingSocket.send(JSON.stringify({
type: 'mediaStatus',
from: localId,
to: remoteId,
status: 'stopped'
}));
localStream.getTracks().forEach(track => {
// 查找并移除对应的发送器
const sender = peerConnection.getSenders().find(s => s.track === track);
if (sender) {
peerConnection.removeTrack(sender);
}
track.stop();
});
// 触发重新协商
renegotiateAfterTrackChange();
} else {
// 如果没有对等连接,只停止轨道
localStream.getTracks().forEach(track => track.stop());
}
localVideo.srcObject = null;
localStream = null;
addDebugMessage('本地视频流已停止');
startVideoBtn.disabled = false;
stopVideoBtn.disabled = true;
}
}
// 添加处理媒体状态变更消息的函数
async function handleMediaStatus(message) {
addDebugMessage(`收到媒体状态变更通知: ${message.status}`);
if (message.status === 'stopped') {
// 对端已停止视频,清除远程视频显示
if (remoteStream) {
// 停止远程流的所有轨道
remoteStream.getTracks().forEach(track => {
try {
track.stop();
} catch (e) {
// 忽略错误
}
});
}
// 清除远程视频显示
remoteStream = null;
remoteVideo.srcObject = null;
addDebugMessage('远程视频流已清除');
} else if (message.status === 'started') {
// 对端已重新开启视频,准备接收新的媒体流
addDebugMessage('对端已重新开启视频');
// 确保 remoteStream 存在
if (!remoteStream) {
remoteStream = new MediaStream();
remoteVideo.srcObject = remoteStream;
}
// 如果有对等连接,触发重新协商
if (peerConnection) {
renegotiateAfterRemoteStarted();
}
}
}
// 当对端重新开启视频时触发重新协商
async function renegotiateAfterRemoteStarted() {
try {
if (!peerConnection || !localStream) return;
addDebugMessage('开始重新协商连接');
// 添加本地媒体轨道(如果尚未添加)
localStream.getTracks().forEach(track => {
const existingSender = peerConnection.getSenders().find(sender =>
sender.track && sender.track.kind === track.kind);
if (!existingSender) {
peerConnection.addTrack(track, localStream);
addDebugMessage(`重新添加媒体轨道: ${track.kind}`);
}
});
// 创建新的offer
const offer = await peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
await peerConnection.setLocalDescription(offer);
// 发送媒体协商offer
signalingSocket.send(JSON.stringify({
type: 'mediaOffer',
from: localId,
to: remoteId,
sdp: offer.sdp
}));
addDebugMessage('已发送重新协商OFFER');
} catch (error) {
addDebugMessage(`重新协商失败: ${error.message}`);
}
}
// 在停止或添加轨道后触发重新协商
async function renegotiateAfterTrackChange() {
try {
if (!peerConnection) return;
// 创建新的offer
const offer = await peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
await peerConnection.setLocalDescription(offer);
// 发送媒体协商offer
signalingSocket.send(JSON.stringify({
type: 'mediaOffer',
from: localId,
to: remoteId,
sdp: offer.sdp
}));
addDebugMessage('已发送重新协商OFFER');
} catch (error) {
addDebugMessage(`重新协商失败: ${error.message}`);
}
}
// 添加重新协商连接的函数
async function renegotiateConnection() {
try {
if (!peerConnection || !localStream) return;
// 检查并添加本地媒体流到连接(避免重复添加)
let tracksAdded = false;
localStream.getTracks().forEach(track => {
// 检查是否已经存在该轨道的发送器
const existingSender = peerConnection.getSenders().find(sender =>
sender.track && sender.track.kind === track.kind);
// 如果不存在相同类型的发送器,则添加新轨道
if (!existingSender) {
peerConnection.addTrack(track, localStream);
addDebugMessage(`添加媒体轨道: ${track.kind}`);
tracksAdded = true;
}
});
// 如果添加了新轨道,则触发重新协商
if (tracksAdded) {
// 创建新的offer
const offer = await peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
await peerConnection.setLocalDescription(offer);
// 发送媒体协商offer
signalingSocket.send(JSON.stringify({
type: 'mediaOffer',
from: localId,
to: remoteId,
sdp: offer.sdp
}));
addDebugMessage('已发送媒体协商OFFER');
}
} catch (error) {
addDebugMessage(`重新协商失败: ${error.message}`);
}
}
// 处理媒体协商消息
async function handleMediaOffer(message) {
try {
if (!peerConnection) {
addDebugMessage('错误:peerConnection未初始化');
return;
}
addDebugMessage('处理媒体协商OFFER');
// 设置远程描述
const offer = new RTCSessionDescription({
type: 'offer',
sdp: message.sdp
});
await peerConnection.setRemoteDescription(offer);
addDebugMessage('已设置远程描述(媒体OFFER)');
// 添加本地媒体流
// if (localStream) {
// localStream.getTracks().forEach(track => {
// peerConnection.addTrack(track, localStream);
// });
// }
// 添加本地媒体流
if (localStream) {
localStream.getTracks().forEach(track => {
// 检查是否已经存在该轨道的发送器
const existingSender = peerConnection.getSenders().find(sender =>
sender.track && sender.track.kind === track.kind);
// 如果不存在相同类型的发送器,则添加新轨道
if (!existingSender) {
peerConnection.addTrack(track, localStream);
addDebugMessage(`添加媒体轨道: ${track.kind}`);
}
});
}
// 创建ANSWER
const answer = await peerConnection.createAnswer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
await peerConnection.setLocalDescription(answer);
addDebugMessage('已创建媒体ANSWER');
// 发送ANSWER
signalingSocket.send(JSON.stringify({
type: 'mediaAnswer',
from: localId,
to: message.from,
sdp: answer.sdp
}));
addDebugMessage('已发送媒体ANSWER');
} catch (error) {
addDebugMessage(`处理媒体OFFER失败: ${error.message}`);
}
}
async function handleMediaAnswer(message) {
try {
if (!peerConnection) {
addDebugMessage('错误:peerConnection未初始化');
return;
}
addDebugMessage('处理媒体协商ANSWER');
// 设置远程描述
await peerConnection.setRemoteDescription(new RTCSessionDescription({
type: 'answer',
sdp: message.sdp
}));
addDebugMessage('已设置远程描述(媒体ANSWER)');
} catch (error) {
addDebugMessage(`处理媒体ANSWER失败: ${error.message}`);
}
}
// 初始化信令连接
function initSignaling() {
try {
// 使用公共测试信令服务器
signalingSocket = new WebSocket('ws://localhost:3000');
signalingSocket.onopen = () => {
signalingStatus.innerHTML = '<i class="fas fa-server"></i> 信令服务器状态: 已连接';
addDebugMessage('信令服务器连接成功');
updateStep(1);
// 注册客户端
signalingSocket.send(JSON.stringify({
type: 'register',
id: localId
}));
};
signalingSocket.onmessage = (event) => {
let message;
if (event.data instanceof Blob) {
// 处理Blob数据
event.data.text().then(text => {
message = JSON.parse(text);
addDebugMessage(`收到信令消息: ${message && message.type}`);
handleSignalingMessage(message);
});
addDebugMessage('收到二进制格式的信令消息');
} else {
message = JSON.parse(event.data);
addDebugMessage(`收到信令消息: ${message && message.type}`);
handleSignalingMessage(message);
}
};
signalingSocket.onerror = (error) => {
addDebugMessage(`信令服务器错误: ${error.message}`);
signalingStatus.innerHTML = '<i class="fas fa-server"></i> 信令服务器状态: 连接错误';
};
signalingSocket.onclose = () => {
addDebugMessage('信令服务器连接已关闭');
signalingStatus.innerHTML = '<i class="fas fa-server"></i> 信令服务器状态: 已断开';
};
} catch (error) {
addDebugMessage(`信令连接失败: ${error.message}`);
signalingStatus.innerHTML = '<i class="fas fa-server"></i> 信令服务器状态: 连接失败';
}
}
// 处理信令消息
function handleSignalingMessage(message) {
if (!peerConnection) return;
switch (message.type) {
case 'offer':
addDebugMessage('收到OFFER,正在处理...');
handleOffer(message);
break;
case 'answer':
addDebugMessage('收到ANSWER,正在处理...');
handleAnswer(message);
break;
case 'candidate':
addDebugMessage('收到ICE候选,正在处理...');
handleCandidate(message);
break;
case 'mediaOffer':
addDebugMessage('收到媒体协商OFFER,正在处理...');
handleMediaOffer(message);
break;
case 'mediaAnswer':
addDebugMessage('收到媒体协商ANSWER,正在处理...');
handleMediaAnswer(message);
break;
case 'mediaStatus':
addDebugMessage('收到媒体状态变更通知,正在处理...');
handleMediaStatus(message);
break;
}
}
// 处理OFFER
async function handleOffer(message) {
try {
if (!peerConnection) {
addDebugMessage('错误:peerConnection未初始化');
return;
}
// 设置远程描述
const offer = new RTCSessionDescription({
type: 'offer',
sdp: message.sdp
});
await peerConnection.setRemoteDescription(offer);
addDebugMessage('已设置远程描述(OFFER)');
addDebugMessage('已设置远程描述(OFFER)');
// // 添加本地媒体流
// if (localStream) {
// localStream.getTracks().forEach(track => {
// peerConnection.addTrack(track, localStream);
// });
// }
// 添加本地媒体流
if (localStream) {
localStream.getTracks().forEach(track => {
// 检查是否已经存在该轨道的发送器
const existingSender = peerConnection.getSenders().find(sender =>
sender.track && sender.track.kind === track.kind);
// 如果不存在相同类型的发送器,则添加新轨道
if (!existingSender) {
peerConnection.addTrack(track, localStream);
addDebugMessage(`添加媒体轨道: ${track.kind}`);
}
});
}
// 创建ANSWER
const answer = await peerConnection.createAnswer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
await peerConnection.setLocalDescription(answer);
addDebugMessage('已创建ANSWER');
// 发送ANSWER
signalingSocket.send(JSON.stringify({
type: 'answer',
from: localId,
to: message.from,
sdp: answer.sdp
}));
addDebugMessage('已发送ANSWER');
updateStep(3);
} catch (error) {
addDebugMessage(`处理OFFER失败: ${error}`);
}
}
// 处理ANSWER
async function handleAnswer(message) {
try {
if (!peerConnection) {
addDebugMessage('错误:peerConnection未初始化');
return;
}
// 设置远程描述
await peerConnection.setRemoteDescription(new RTCSessionDescription({
type: 'answer',
sdp: message.sdp
}));
addDebugMessage('已设置远程描述(ANSWER)');
updateStep(3);
} catch (error) {
addDebugMessage(`处理ANSWER时出错: ${error.message}`);
}
}
// 处理ICE候选
async function handleCandidate(message) {
try {
if (!peerConnection) {
addDebugMessage('错误:peerConnection未初始化');
return;
}
if (message.candidate && message.candidate.candidate) {
await peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate));
addDebugMessage('成功添加ICE候选: ' + message.candidate.candidate);
} else {
addDebugMessage('收到空的ICE候选,可能是结束信号');
}
} catch (error) {
addDebugMessage(`添加候选失败: ${error.message}`);
}
}
// 初始化WebRTC连接
async function initConnection() {
try {
addDebugMessage('正在初始化WebRTC连接...');
// 创建配置 - 使用公共STUN服务器
const config = {
iceServers: [
// 国内可尝试的 (测试用,不稳定!)
{ urls: "stun:localhost:3478" },
{ urls: "stun:stun.voipstunt.com:3478" },
{ urls: "stun:stun.ekiga.net:3478" },
{ urls: "stun:stun.voxgratia.org:3478" },
{ urls: "stun:stun.internetcalls.com:3478" },
{ urls: "stun:stun.voip.aebc.com:3478" },
{ urls: "stun:stun.internetcalls.com:3478" },
{ urls: "stun:stun.miwifi.com:3478" },
{ urls: "stun:stun.qq.com:3478" },
// 全球知名的 (非国内,延迟可能高)
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
{ urls: "stun:stun2.l.google.com:19302" },
{
urls: "turn:freeturn.net:80",
username: "free",
credential: "free"
},
{
urls: "turn:freeturn.tel:80",
username: "free",
credential: "free"
},
]
};
// 创建对等连接
peerConnection = new RTCPeerConnection(config);
// 添加本地媒体流到连接
// if (localStream) {
// localStream.getTracks().forEach(track => {
// peerConnection.addTrack(track, localStream);
// });
// }
// 添加本地媒体流到连接(避免重复添加)
if (localStream) {
localStream.getTracks().forEach(track => {
// 检查是否已经存在该轨道的发送器
const existingSender = peerConnection.getSenders().find(sender =>
sender.track && sender.track.kind === track.kind);
// 如果不存在相同类型的发送器,则添加新轨道
if (!existingSender) {
peerConnection.addTrack(track, localStream);
addDebugMessage(`添加媒体轨道: ${track.kind}`);
}
});
}
// 设置远程流接收
peerConnection.ontrack = (event) => {
addDebugMessage('收到远程媒体流');
if (!remoteStream) {
remoteStream = new MediaStream();
remoteVideo.srcObject = remoteStream;
}
remoteStream.addTrack(event.track);
};
// 设置ICE候选处理
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
signalingSocket.send(JSON.stringify({
type: 'candidate',
from: localId,
to: remoteId,
candidate: event.candidate
}));
addDebugMessage('已发送ICE候选');
} else {
addDebugMessage('ICE候选收集完成');
}
};
// 设置ICE连接状态变化
peerConnection.oniceconnectionstatechange = () => {
addDebugMessage(`ICE连接状态: ${peerConnection.iceConnectionState}`);
if (peerConnection.iceConnectionState === 'connected') {
updateStep(4);
}
};
// 如果是发起方,创建数据通道
if (!dataChannel) {
dataChannel = peerConnection.createDataChannel("messaging", {
ordered: true
});
setupDataChannelEvents();
}
// 设置远程数据通道事件
peerConnection.ondatachannel = (event) => {
addDebugMessage('收到远程数据通道');
dataChannel = event.channel;
setupDataChannelEvents();
};
// 创建包含媒体信息的OFFER
const offer = await peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
await peerConnection.setLocalDescription(offer);
addDebugMessage('已创建OFFER');
// 发送OFFER
signalingSocket.send(JSON.stringify({
type: 'offer',
from: localId,
to: remoteId,
sdp: offer.sdp
}));
addDebugMessage('已发送OFFER');
updateStep(2);
// 更新状态
connectionStatus.textContent = "连接中...";
connectionStatus.style.color = "#ffcc00";
} catch (error) {
addDebugMessage(`初始化连接失败: ${error.message}`);
connectionStatus.textContent = "连接失败";
connectionStatus.style.color = "#ff6b6b";
}
}
// 设置数据通道事件
function setupDataChannelEvents() {
if (!dataChannel) return;
// ICE状态跟踪
peerConnection.oniceconnectionstatechange = () => {
const state = peerConnection.iceConnectionState;
addDebugMessage(`ICE连接状态变化: ${state}`);
switch(state) {
case 'checking':
addDebugMessage('正在进行ICE连接检查...');
break;
case 'connected':
addDebugMessage('ICE连接已建立');
updateStep(4);
// 确保连接状态更新
connectionStatus.textContent = "已连接";
connectionStatus.style.color = "#00ff80";
connectBtn.disabled = true;
disconnectBtn.disabled = false;
messageInput.disabled = false;
sendBtn.disabled = false;
connectionTypeSpan.textContent = "P2P直连";
break;
case 'completed':
addDebugMessage('ICE连接完成');
break;
case 'disconnected':
addDebugMessage('ICE连接断开,尝试重新连接...');
// 不立即重连,等待一段时间看是否能自动恢复
setTimeout(() => {
if (peerConnection && peerConnection.iceConnectionState === 'disconnected') {
addDebugMessage('ICE连接仍未恢复,尝试重启');
// 尝试收集新的候选
peerConnection.restartIce();
}
}, 3000);
break;
case 'failed':
addDebugMessage('ICE连接失败,尝试重启...');
peerConnection.restartIce();
break;
case 'closed':
addDebugMessage('ICE连接已关闭');
break;
}
};
dataChannel.onopen = () => {
addDebugMessage('数据通道已打开');
connectionStatus.textContent = "已连接";
connectionStatus.style.color = "#00ff80";
connectBtn.disabled = true;
disconnectBtn.disabled = false;
messageInput.disabled = false;
sendBtn.disabled = false;
connectionTypeSpan.textContent = "P2P直连";
// 添加欢迎消息
addMessage('系统', 'WebRTC点对点连接已建立!现在可以发送消息了', 'remote');
};
dataChannel.onclose = () => {
setTimeout(() => {
addDebugMessage('尝试重新连接...');
initConnection();
}, 2000);
};
dataChannel.onerror = (error) => {
addDebugMessage(`数据通道错误: ${error.message}`);
};
dataChannel.onmessage = (event) => {
addDebugMessage(`收到消息: ${event.data}`);
messagesReceived++;
messagesReceivedSpan.textContent = messagesReceived;
addMessage('远程端', event.data, 'remote');
};
}
// 添加消息到聊天框
function addMessage(sender, text, type) {
const messageElement = document.createElement('div');
messageElement.classList.add('message', type);
const now = new Date();
const timeString = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
messageElement.innerHTML = `
<div>${text}</div>
<div class="message-info">${sender} • ${timeString}</div>
`;
chatMessages.appendChild(messageElement);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// 发送消息
function sendMessage() {
const message = messageInput.value.trim();
if (!message) return;
if (dataChannel && dataChannel.readyState === 'open') {
dataChannel.send(message);
messagesSent++;
messagesSentSpan.textContent = messagesSent;
addMessage('我', message, 'local');
messageInput.value = '';
addDebugMessage(`消息已发送: ${message}`);
} else {
addMessage('系统', '数据通道尚未打开,无法发送消息', 'remote');
}
}
// 断开连接
function disconnect() {
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
if (dataChannel) {
dataChannel.close();
dataChannel = null;
}
// 停止本地视频流
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
localVideo.srcObject = null;
}
// 清除远程视频流
remoteVideo.srcObject = null;
remoteStream = null;
connectionStatus.textContent = "未连接";
connectionStatus.style.color = "#ff6b6b";
connectBtn.disabled = false;
disconnectBtn.disabled = true;
messageInput.disabled = true;
sendBtn.disabled = true;
connectionTypeSpan.textContent = "-";
startVideoBtn.disabled = false;
stopVideoBtn.disabled = true;
addDebugMessage('连接已断开');
resetSteps();
// 添加断开消息
addMessage('系统', '连接已断开', 'remote');
}
// 事件监听器
startVideoBtn.addEventListener('click', startLocalVideo);
stopVideoBtn.addEventListener('click', stopLocalVideo);
connectBtn.addEventListener('click', () => {
remoteId = remoteIdInput.value.trim();
if (!remoteId) {
addMessage('系统', '请输入对等端ID', 'remote');
return;
}
if (remoteId === localId) {
addMessage('系统', '不能连接到自己的ID', 'remote');
return;
}
initConnection();
addMessage('系统', `正在连接对等端: ${remoteId}`, 'remote');
});
disconnectBtn.addEventListener('click', disconnect);
sendBtn.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendMessage();
}
});
clearDebugBtn.addEventListener('click', () => {
debugOutput.innerHTML = '';
});
// 初始化
initLocalId();
initSignaling();
});
</script>
</body>
</html>
总结
这个WebRTC视频聊天系统展示了现代Web实时通信技术的强大能力。通过WebRTC、WebSocket和现代Web API的结合,实现了高质量的点对点音视频通信和文本消息传输。系统具有良好的可扩展性和稳定性,为开发者提供了学习和参考的优秀示例。 对于希望深入了解WebRTC技术的开发者来说,这个项目提供了完整的实践案例,涵盖了从连接建立、媒体处理到数据传输的全流程实现。