Skip to content

WebRTC - 视频演示

在本章中,我们将构建一个客户端应用,使不同设备上的两名用户能够使用 WebRTC 建立视频通话。该应用将包含一个登录页面和一个用于发起及管理视频通话的主页面。

原始教程使用图片展示了登录页面:一个简单的界面,包含用户名输入字段和“登录”按钮。另一张图片描绘了呼叫页面,其中包含一个用于显示远端视频的大区域、一个用于显示本地视频的小插图、一个“呼叫对象用户名”输入字段、“呼叫”按钮和“挂断”按钮。我们在定义 HTML 结构时,将通过文本描述这些元素。

应用的视图(登录页和呼叫页)将通过切换 div 元素的可见性来管理。用户交互通过 JavaScript 事件监听器处理。

信令服务器对于 WebRTC 至关重要,它允许对等方(peers)在建立直接的点对点连接(peer-to-peer connection)之前交换连接元数据(metadata)(SDP offer/answer、ICE candidate)。我们将使用在“WebRTC - 现代语音演示”章节中开发的基于 WebSocket 的同一信令服务器。对于此视频演示,无需修改 server.js 文件。

请确保您已安装 Node.js 和 ws 库(library)(npm install ws)。服务器代码可在语音演示章节中找到。在其目录下运行 node server.js 即可启动。

测试此视频聊天应用通常涉及在两个单独的浏览器标签页(browser tabs)或两个不同的设备(devices)上打开它,并在它们之间发起呼叫。

首先,创建一个 index.html 文件。我们将通过 CDN(Content Delivery Network,内容分发网络)使用 Bootstrap 进行样式设置。将此文件及后续的 client.js 放在一个新文件夹(folder)中(例如,webrtc-video-app)。

index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebRTC Video Demo</title>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #f0f0f0; padding: 20px; }
.container { max-width: 800px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
#loginPage, #callPage { margin-top: 20px; }
video { background: black; border: 1px solid gray; width: 100%; }
#localVideoContainer { position: relative; margin-bottom: 10px; }
#localVideo {
width: 200px; /* Smaller size for local video */
height: 150px;
position: absolute;
top: 10px;
right: 10px;
border: 2px solid white;
z-index: 100; /* Ensure it's on top */
}
#remoteVideo { height: 480px; /* Larger size for remote video */ }
</style>
</head>
<body>
<div class="container">
<div id="loginPage">
<h2 class="text-center">WebRTC Video Demo</h2>
<p class="text-center">Please sign in</p>
<div class="form-group">
<input type="text" id="usernameInput" class="form-control" placeholder="Enter your username" autofocus required>
</div>
<button id="loginBtn" class="btn btn-primary btn-block">Sign In</button>
</div>
<div id="callPage" style="display: none;">
<h3 class="text-center">Hi, <span id="currentUser"></span>! You are in a call.</h3>
<div id="localVideoContainer">
<video id="remoteVideo" autoplay playsinline></video> <!-- Main remote video -->
<video id="localVideo" autoplay playsinline muted></video> <!-- Small local video overlay -->
</div>
<hr>
<div class="form-group">
<input type="text" id="callToUsernameInput" class="form-control" placeholder="Username to call">
</div>
<button id="callBtn" class="btn btn-success">Call</button>
<button id="hangUpBtn" class="btn btn-danger">Hang Up</button>
</div>
</div>
<script src="client.js"></script>
</body>
</html>

此 HTML 结构包含两个

现在,创建 client.js:

// client.js
let name;
let connectedUser;
let localStream;
let peerConnection;
const wsURL = 'ws://localhost:9090';
const connection = new WebSocket(wsURL);
// DOM 元素
const loginPage = document.querySelector('#loginPage');
const usernameInput = document.querySelector('#usernameInput');
const loginBtn = document.querySelector('#loginBtn');
const callPage = document.querySelector('#callPage');
const currentUserDisplay = document.querySelector('#currentUser');
const callToUsernameInput = document.querySelector('#callToUsernameInput');
const callBtn = document.querySelector('#callBtn');
const hangUpBtn = document.querySelector('#hangUpBtn');
const localVideo = document.querySelector('#localVideo');
const remoteVideo = document.querySelector('#remoteVideo');
// ICE 服务器配置
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
// 如需进行 NAT 穿越,请在此处添加 TURN 服务器
]
};
connection.onopen = () => {
console.log('Connected to signaling server');
};
connection.onmessage = async (msg) => {
console.log('Got message', msg.data);
const data = JSON.parse(msg.data);
switch (data.type) {
case 'login':
await handleLogin(data.success, data.message); // 因 getUserMedia 而设为异步
break;
case 'offer':
await handleOffer(data.offer, data.name);
break;
case 'answer':
await handleAnswer(data.answer);
break;
case 'candidate':
await handleCandidate(data.candidate);
break;
case 'leave':
handleLeave();
break;
case 'error':
alert(`Error: ${data.message}`);
break;
default:
break;
}
};
connection.onerror = (err) => {
console.error('WebSocket error:', err);
alert('Error connecting to signaling server.');
};
function send(message) {
if (connectedUser) {
message.name = connectedUser; // 信令消息的目标用户
}
connection.send(JSON.stringify(message));
}
// UI 事件监听器
loginBtn.addEventListener('click', () => {
name = usernameInput.value;
if (name.length > 0) {
send({ type: 'login', name: name });
} else {
alert('Please enter a username.');
}
});
async function handleLogin(success, message) {
if (success === false) {
alert(`Login failed: ${message || 'Try a different username.'}`);
} else {
loginPage.style.display = 'none';
callPage.style.display = 'block';
currentUserDisplay.textContent = name;
try {
// 获取本地视频和音频流
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localVideo.srcObject = localStream;
// 获取媒体后初始化 RTCPeerConnection
initializePeerConnection();
} catch (error) {
console.error('Error accessing media devices:', error);
alert('Could not access camera/microphone: ' + error.message);
// 恢复 UI 或优雅处理
loginPage.style.display = 'block';
callPage.style.display = 'none';
}
}
}
function initializePeerConnection() {
peerConnection = new RTCPeerConnection(configuration);
// 将本地流轨道添加到对等连接
if (localStream) {
localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream));
}
// 处理接收到的远程轨道
peerConnection.ontrack = (event) => {
console.log('Remote track received:', event.track.kind);
if (event.streams && event.streams[0]) {
remoteVideo.srcObject = event.streams[0];
} else {
// 针对旧版浏览器或特定轨道事件场景的备用方案
let inboundStream = new MediaStream();
inboundStream.addTrack(event.track);
remoteVideo.srcObject = inboundStream;
}
};
// 处理 ICE 候选者
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
send({ type: 'candidate', candidate: event.candidate });
}
};
// 记录连接状态变化(可选,用于调试)
peerConnection.oniceconnectionstatechange = () => {
console.log(`ICE connection state: ${peerConnection.iceConnectionState}`);
if (peerConnection.iceConnectionState === 'failed' ||
peerConnection.iceConnectionState === 'disconnected' ||
peerConnection.iceConnectionState === 'closed') {
// 在此处处理连接失败
// 在此演示中,我们主要依赖显式的“离开”或挂断
}
};
}
callBtn.addEventListener('click', async () => {
const callToUsername = callToUsernameInput.value;
if (callToUsername.length === 0) {
alert('Please enter a username to call.');
return;
}
if (callToUsername === name) {
alert('You cannot call yourself.');
return;
}
connectedUser = callToUsername;
console.log(`Initiating call to ${connectedUser}`);
if (!peerConnection) initializePeerConnection(); // 确保 peerConnection 已初始化
// if (!localStream) { alert('Local media not ready'); return; } // 可以添加此检查
try {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
send({ type: 'offer', offer: offer });
} catch (error) {
console.error('Error creating offer:', error);
alert('Error creating call offer: ' + error.message);
}
});
async function handleOffer(offer, fromName) {
if (!peerConnection) initializePeerConnection(); // 确保 PC 已准备就绪
// if (!localStream) { alert('Local media not ready for incoming call'); return; } // 可以添加此检查
connectedUser = fromName;
console.log(`Received offer from ${connectedUser}`);
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
send({ type: 'answer', answer: answer });
} catch (error) {
console.error('Error handling offer:', error);
alert('Error handling incoming call: ' + error.message);
}
}
async function handleAnswer(answer) {
console.log('Received answer');
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
} catch (error) {
console.error('Error handling answer:', error);
}
}
async function handleCandidate(candidate) {
try {
if (candidate) {
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
}
} catch (error) {
console.error('Error adding ICE candidate:', error);
}
}
hangUpBtn.addEventListener('click', () => {
send({ type: 'leave' });
handleLeave();
});
function handleLeave() {
console.log('Call ended or other user left.');
connectedUser = null;
if (remoteVideo) remoteVideo.srcObject = null;
if (peerConnection) {
peerConnection.close();
peerConnection.onicecandidate = null;
peerConnection.ontrack = null;
peerConnection.oniceconnectionstatechange = null;
peerConnection = null; // 置空,以便下次呼叫时重新创建
}
callToUsernameInput.value = ''; // 清空输入字段
// 如果 localStream 仍然活跃,则为新呼叫重新初始化 peerConnection
// if (localStream) initializePeerConnection();
}
// 在窗口关闭时进行清理
window.addEventListener('beforeunload', () => {
if (name) { // 如果已登录
send({ type: 'leave' }); // 通知对方
}
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
}
if (peerConnection) {
peerConnection.close();
}
if (connection) {
connection.close();
}
});

client.js 中针对视频的关键更新和特性:

  • 媒体约束(Media Constraints):navigator.mediaDevices.getUserMedia({ video: true, audio: true }) 请求同时获取视频和音频。
  • DOM 元素:使用了 localVideo 和 remoteVideo 元素的 DOM 选择器。
  • srcObject:本地和远程媒体流通过 srcObject 属性附加到各自的
  • 现代 API:使用 navigator.mediaDevices.getUserMedia、RTCPeerConnection、addTrack、ontrack 和 async/await 进行基于 Promise 的操作,与语音演示保持一致。
  • 初始化:initializePeerConnection() 在成功登录并获取媒体后调用。它设置 RTCPeerConnection 及其关键的事件处理器(ontrack、onicecandidate)。
  • 轨道处理:localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream)) 将本地流中的所有轨道(track)添加到连接中。peerConnection.ontrack 处理接收到的轨道,并将其附加到 remoteVideo 元素。
  • 清理:handleLeave() 函数和 beforeunload 事件监听器确保 peerConnection 被关闭,并且媒体流(media streams)被停止和清理。

运行此视频演示:

  1. 保存 server.js(来自语音演示)、index.html 和 client.js 文件。
  2. 启动信令服务器:node server.js。
  3. 在两个不同的浏览器标签页(browser tabs)中打开 index.html(如果您的网络/STUN 配置允许,也可以在不同设备(devices)上打开)。
  4. 在每个标签页中用唯一的用户名登录。浏览器将请求访问摄像头和麦克风的权限。
  5. 原始教程展示了此权限提示和本地视频流出现的图片。当前设置也实现了这一点。
  6. 在“呼叫对象用户名”字段中输入另一个标签页的用户名,然后点击“呼叫”。

您应该在 remoteVideo 元素中看到对方的视频,在 localVideo 元素中看到您自己的视频。原始教程中也有图片示例展示了用户 ‘userA’ 和 ‘userB’ 之间的成功通话,显示了本地和远程视频画面,以及挂断后的状态。

此视频演示基于语音演示构建,主要更改了媒体约束以包含视频,并更新了 HTML 以显示视频元素。核心 WebRTC 信令及连接逻辑保持非常相似。此示例为构建更复杂的视频通信应用提供了坚实的基础。对于生产环境,请考虑健壮的错误处理、UI/UX 增强以及用于可靠 NAT(网络地址转换)穿越的 TURN 服务器。