跳到主要内容WebRTC 实现无插件多端视频通话 | 极客日志Java大前端java
WebRTC 实现无插件多端视频通话
基于 WebRTC 技术实现无插件多端视频通话的方案。主要内容包括:搭建基于 SpringBoot 和 WebSocket 的后端信令服务器,处理 SDP 交换、ICE 候选转发及通话排队逻辑;编写前端 HTML5 页面,利用 jQuery 和原生 WebRTC API 实现音视频采集、P2P 连接建立及 UI 交互;配置浏览器以支持内网 IP 访问摄像头权限。方案支持一对一呼叫及多对一排队呼叫功能,具备完善的连接状态管理和异常处理机制。
岁月神偷34 浏览 环境
- 前端:HTML5 + jQuery
- 后端:JDK-1.8 + SpringBoot-1.4.2
- 浏览器:谷歌/火狐/360
简介
WebRTC 负责浏览器间直接的音视频数据传输,HTML 负责前端音视频的采集和展示,信令服务器则是'牵线搭桥'的角色,解决 WebRTC 无法直接交换连接信息的问题。本文以实现网页端之间的视频通话为主,安卓端需要自行开发测试,原理是相通的。
| 概念 | 作用 |
|---|
| WebRTC | 浏览器原生的实时通信 API,让两个浏览器(端)直接建立 P2P 连接,实现无插件传输音视频/数据 |
| RTCPeerConnection | WebRTC 核心对象,负责管理 P2P 连接、处理音视频数据传输、收集 ICE 候选 |
| SDP | 描述音视频编码格式、网络信息等会话规则 |
| ICE | 解决 NAT/防火墙穿透问题,生成可访问的网络地址(ICE 候选),让不同内网的设备能找到彼此 |
| HTML | 通过 video 标签展示音视频流,配合 JavaScript 调用 WebRTC API 完成采集、连接等逻辑 |
| WebSocket | 实现浏览器与信令服务器之间的双向实时通信 |
| 信令服务器 | 负责交换连接参数(如 SDP、ICE 候选)和通话的处理结果 |
一、信令服务器
1、添加 pom 依赖(核心是 websocket)
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<>org.springframework.boot
spring-boot-starter-websocket
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
org.junit.vintage
junit-vintage-engine
groupId
</groupId>
<artifactId>
</artifactId>
</dependency>
<dependency>
<groupId>
</groupId>
<artifactId>
</artifactId>
<optional>
</optional>
</dependency>
<dependency>
<groupId>
</groupId>
<artifactId>
</artifactId>
<scope>
</scope>
<exclusions>
<exclusion>
<groupId>
</groupId>
<artifactId>
</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
2、配置实体类 (呼叫排队、信令消息、消息类型枚举)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CallWaitItem {
private String fromUserId;
private String toUserId;
private String offerData;
private long queueTime;
private int queueIndex;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class WebrtcMessage {
private String type;
private String fromUserId;
private String toUserId;
private String data;
}
@Getter
public enum WebrtcMessageType {
OFFER("offer"),
ANSWER("answer"),
ICE_CANDIDATE("iceCandidate"),
LEAVE("leave"),
REJECT("reject"),
ERROR("error"),
PING("ping"),
QUEUE_UPDATE("queueUpdate"),
QUEUE_TIMEOUT("queueTimeout"),
OFFLINE_NOTIFY("offlineNotify");
private final String type;
WebrtcMessageType(String type) {
this.type = type;
}
public static WebrtcMessageType getByType(String type) {
for (WebrtcMessageType messageType : values()) {
if (messageType.getType().equals(type)) {
return messageType;
}
}
return null;
}
}
3、添加 WebRTC 信令消息处理器(转发消息 + 通话逻辑处理)
@Slf4j
@Component
public class WebrtcSignalingHandler extends TextWebSocketHandler {
private static final Map<String, WebSocketSession> ONLINE_SESSIONS = new ConcurrentHashMap<>();
private static final Map<String, String> CALL_BIND_MAP = new ConcurrentHashMap<>();
private static final Map<String, List<CallWaitItem>> CALL_WAIT_QUEUE_MAP = new ConcurrentHashMap<>();
private static final long QUEUE_TIMEOUT_MS = 5 * 60 * 1000;
private static final boolean ENABLE_QUEUE = true;
private static final long PING_TIMEOUT_MS = 3000;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String userId = getUserIdFromSession(session);
if (userId == null || userId.trim().isEmpty()) {
session.close(CloseStatus.BAD_DATA.withReason("缺少有效的用户 ID(URL 拼接?userId=[ID])"));
log.warn("【WebSocket 连接失败】缺少有效用户 ID,会话 URL:{},会话 ID:{}", session.getUri(), session.getId());
return;
}
if (ONLINE_SESSIONS.containsKey(userId)) {
WebSocketSession oldSession = ONLINE_SESSIONS.get(userId);
if (oldSession != null && oldSession.isOpen()) {
oldSession.close(CloseStatus.POLICY_VIOLATION.withReason("该账号在其他设备登录,已被强制下线"));
log.info("【WebSocket 重复登录】用户【{}】在新设备登录,已强制下线旧设备(旧会话 ID:{})", userId, oldSession.getId());
}
ONLINE_SESSIONS.remove(userId);
}
ONLINE_SESSIONS.put(userId, session);
session.getAttributes().put("userId", userId);
log.info("【WebSocket 连接成功】用户【{}】,会话 ID:{},当前在线人数:{}", userId, session.getId(), ONLINE_SESSIONS.size());
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String userId = (String) session.getAttributes().get("userId");
if (userId == null) {
log.warn("【WebSocket 连接关闭】会话无有效用户 ID,会话 ID:{},关闭状态:{}", session.getId(), status);
return;
}
cleanUserRelatedData(userId, true);
log.info("【WebSocket 连接关闭】用户【{}】,会话 ID:{},当前在线人数:{},关闭状态:{}", userId, session.getId(), ONLINE_SESSIONS.size(), status);
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
String userId = (String) session.getAttributes().get("userId");
if (userId != null) {
cleanUserRelatedData(userId, false);
log.error("【WebSocket 会话异常】用户【{}】,会话 ID:{},当前在线人数:{}", userId, session.getId(), ONLINE_SESSIONS.size(), exception);
} else {
log.warn("【WebSocket 会话异常】无有效用户 ID,会话 ID:{}", session.getId(), exception);
}
if (session.isOpen()) {
session.close(CloseStatus.SERVER_ERROR.withReason("会话内部异常,已自动关闭"));
}
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String fromUserId = (String) session.getAttributes().get("userId");
if (fromUserId == null) {
log.warn("【消息处理失败】发送方会话无有效用户 ID,会话 ID:{}", session.getId());
return;
}
String rawMessage = message.getPayload();
log.info("【收到信令消息】用户【{}】,会话 ID:{},原始消息内容:{}", fromUserId, session.getId(), rawMessage);
try {
WebrtcMessage webrtcMessage = OBJECT_MAPPER.readValue(rawMessage, WebrtcMessage.class);
webrtcMessage.setFromUserId(fromUserId);
String toUserId = webrtcMessage.getToUserId();
if (toUserId == null || toUserId.trim().isEmpty()) {
sendErrorMsg(session, "缺少接收方用户 ID(toUserId)");
return;
}
if (fromUserId.equals(toUserId)) {
sendErrorMsg(session, "无法呼叫自身,请输入其他用户 ID");
return;
}
WebrtcMessageType msgType = WebrtcMessageType.getByType(webrtcMessage.getType());
if (msgType == null) {
sendErrorMsg(session, "不支持的消息类型:" + webrtcMessage.getType());
return;
}
switch (msgType) {
case OFFER:
handleOfferMessage(session, webrtcMessage, fromUserId, toUserId);
break;
case ANSWER:
handleAnswerMessage(webrtcMessage, fromUserId, toUserId);
break;
case REJECT:
handleRejectMessage(webrtcMessage, fromUserId, toUserId);
break;
case LEAVE:
handleLeaveMessage(webrtcMessage, fromUserId, toUserId);
break;
case ICE_CANDIDATE:
handleIceCandidateMessage(webrtcMessage, fromUserId, toUserId);
break;
case PING:
break;
default:
sendErrorMsg(session, "暂未实现的消息类型:" + msgType.getType());
}
} catch (Exception e) {
sendErrorMsg(session, "消息处理失败:格式错误或服务器内部异常");
log.error("【消息处理异常】用户【{}】,会话 ID:{},原始消息:{}", fromUserId, session.getId(), rawMessage, e);
}
}
private void handleOfferMessage(WebSocketSession session, WebrtcMessage message, String fromUserId, String toUserId) throws Exception {
log.info("【处理发起呼叫】呼叫方【{}】→ 被叫方【{}】,开始处理 OFFER 消息", fromUserId, toUserId);
if (!isUserOnline(toUserId)) {
log.warn("【发起呼叫失败】被叫方【{}】未在线或连接已关闭,呼叫方【{}】", toUserId, fromUserId);
sendErrorMsg(session, "对方【" + toUserId + "】未在线或连接已关闭");
return;
}
if (isUserBusy(toUserId)) {
if (!ENABLE_QUEUE) {
log.warn("【发起呼叫失败】被叫方【{}】正忙,未开启排队功能,呼叫方【{}】", toUserId, fromUserId);
sendErrorMsg(session, "对方【" + toUserId + "】正忙,无法接听(未开启排队功能)");
return;
}
log.info("【被叫方忙线】呼叫方【{}】→ 被叫方【{}】,准备加入排队队列", fromUserId, toUserId);
List<CallWaitItem> waitQueue = CALL_WAIT_QUEUE_MAP.getOrDefault(toUserId, new CopyOnWriteArrayList<>());
boolean isDuplicate = waitQueue.stream().anyMatch(item -> item.getFromUserId().equals(fromUserId));
if (isDuplicate) {
CallWaitItem existItem = waitQueue.stream()
.filter(item -> item.getFromUserId().equals(fromUserId))
.findFirst()
.orElse(null);
if (existItem != null) {
sendQueueUpdateMsg(getUserSession(fromUserId), toUserId, existItem.getQueueIndex(), waitQueue.size());
log.debug("【排队去重】呼叫方【{}】已在被叫方【{}】的排队队列中(第{}位),无需重复加入", fromUserId, toUserId, existItem.getQueueIndex());
}
return;
}
CallWaitItem waitItem = new CallWaitItem();
waitItem.setFromUserId(fromUserId);
waitItem.setToUserId(toUserId);
waitItem.setOfferData(message.getData());
waitItem.setQueueTime(System.currentTimeMillis());
waitItem.setQueueIndex(waitQueue.size() + 1);
waitQueue.add(waitItem);
CALL_WAIT_QUEUE_MAP.put(toUserId, waitQueue);
sendQueueUpdateMsg(session, toUserId, waitItem.getQueueIndex(), waitQueue.size());
log.info("用户【{}】呼叫【{}】:被叫方忙线,已加入排队(第{}位,总{}人)", fromUserId, toUserId, waitItem.getQueueIndex(), waitQueue.size());
return;
}
CALL_BIND_MAP.put(toUserId, fromUserId);
forwardMessage(message, toUserId);
log.info("【转发 Offer 成功】呼叫方【{}】→ 被叫方【{}】,对方空闲,已完成临时通话绑定", fromUserId, toUserId);
}
private void handleAnswerMessage(WebrtcMessage message, String answererId, String callerId) throws Exception {
log.info("【处理接听呼叫】接听方【{}】→ 呼叫方【{}】,开始处理 ANSWER 消息", answererId, callerId);
if (!isUserOnline(callerId) || !isUserOnline(answererId)) {
log.warn("【接听呼叫失败】双方或一方已离线,接听方【{}】,呼叫方【{}】", answererId, callerId);
sendErrorMsg(getUserSession(answererId), "对方已离线,无法完成接听");
return;
}
CALL_BIND_MAP.put(callerId, answererId);
CALL_BIND_MAP.put(answererId, callerId);
forwardMessage(message, callerId);
log.info("【建立双向通话成功】接听方【{}】↔ 呼叫方【{}】,已完成双向通话绑定,开始传输媒体流", answererId, callerId);
}
private void handleRejectMessage(WebrtcMessage message, String rejecterId, String callerId) throws Exception {
log.info("【处理拒接呼叫】拒接方【{}】→ 呼叫方【{}】,开始处理 REJECT 消息", rejecterId, callerId);
WebSocketSession callerSession = getUserSession(callerId);
if (callerSession != null && callerSession.isOpen()) {
message.setData("对方【" + rejecterId + "】已拒接你的呼叫");
forwardMessage(message, callerId);
log.debug("【拒接通知发送成功】拒接方【{}】→ 呼叫方【{}】", rejecterId, callerId);
}
cleanCallBind(rejecterId, callerId);
processNextWaitItem(rejecterId);
log.info("【拒接呼叫处理完成】拒接方【{}】,呼叫方【{}】,已清理通话绑定并触发下一个排队项", rejecterId, callerId);
}
private void handleLeaveMessage(WebrtcMessage message, String operatorId, String targetId) throws Exception {
log.info("【处理主动挂断】操作方【{}】→ 目标方【{}】,开始处理 LEAVE 消息", operatorId, targetId);
if ("cancelCall".equals(message.getData())) {
if (isUserOnline(targetId)) {
message.setData("对方【" + operatorId + "】已取消呼叫");
forwardMessage(message, targetId);
log.debug("【取消呼叫发送成功】操作方【{}】→ 目标方【{}】", operatorId, targetId);
}
for (List<CallWaitItem> waitQueue : CALL_WAIT_QUEUE_MAP.values()) {
waitQueue.removeIf(item -> item.getFromUserId().equals(operatorId));
}
List<CallWaitItem> updatedWaitQueue = CALL_WAIT_QUEUE_MAP.getOrDefault(targetId, new CopyOnWriteArrayList<>());
if (operatorId.equals(CALL_BIND_MAP.get(targetId))) {
cleanCallBind(operatorId, targetId);
}
if (CALL_BIND_MAP.get(targetId) == null) {
if (updatedWaitQueue.size() > 0) {
CallWaitItem nextItem = updatedWaitQueue.remove(0);
try {
WebrtcMessage offerMsg = new WebrtcMessage();
offerMsg.setType(WebrtcMessageType.OFFER.getType());
offerMsg.setFromUserId(nextItem.getFromUserId());
offerMsg.setToUserId(targetId);
offerMsg.setData(nextItem.getOfferData());
CALL_BIND_MAP.put(targetId, nextItem.getFromUserId());
forwardMessage(offerMsg, targetId);
updateWaitQueueIndex(updatedWaitQueue, targetId);
log.info("【队列调度成功】呼叫方【{}】的 Offer 消息已转发给被叫方【{}】,剩余排队人数:{}", nextItem.getFromUserId(), targetId, updatedWaitQueue.size());
} catch (Exception e) {
log.error("【队列调度失败】转发 Offer 消息给被叫方【{}】异常,排队项:{}", targetId, nextItem, e);
processNextWaitItem(targetId);
}
}
} else {
updateWaitQueueIndex(updatedWaitQueue, targetId);
}
log.info("【主动取消处理完成】操作方【{}】,目标方【{}】,已清理通话绑定并触发排队项调度", operatorId, targetId);
} else {
if (isUserOnline(targetId)) {
message.setData("对方【" + operatorId + "】已挂断通话");
forwardMessage(message, targetId);
log.debug("【挂断通知发送成功】操作方【{}】→ 目标方【{}】", operatorId, targetId);
}
cleanCallBind(operatorId, targetId);
processNextWaitItem(operatorId);
processNextWaitItem(targetId);
log.info("【主动挂断处理完成】操作方【{}】,目标方【{}】,已清理通话绑定并触发排队项调度", operatorId, targetId);
}
}
private void handleIceCandidateMessage(WebrtcMessage message, String fromUserId, String toUserId) throws Exception {
log.debug("【处理 ICE 候选消息】发送方【{}】→ 接收方【{}】,准备转发", fromUserId, toUserId);
if (isUserOnline(toUserId)) {
forwardMessage(message, toUserId);
log.info("【ICE 候选消息转发成功】发送方【{}】→ 接收方【{}】", fromUserId, toUserId);
} else {
log.warn("【ICE 候选消息转发失败】接收方【{}】已离线,发送方【{}】", toUserId, fromUserId);
sendErrorMsg(getUserSession(fromUserId), "对方已离线,无法转发 ICE 候选消息");
}
}
private void processNextWaitItem(String toUserId) {
if (!ENABLE_QUEUE) {
CALL_WAIT_QUEUE_MAP.remove(toUserId);
log.debug("【排队功能关闭】已清理被叫方【{}】的排队队列", toUserId);
return;
}
List<CallWaitItem> waitQueue = CALL_WAIT_QUEUE_MAP.getOrDefault(toUserId, new CopyOnWriteArrayList<>());
if (waitQueue.isEmpty()) {
CALL_WAIT_QUEUE_MAP.remove(toUserId);
log.debug("【排队队列为空】被叫方【{}】无待处理的排队项,已清理队列映射", toUserId);
return;
}
CallWaitItem nextItem = waitQueue.remove(0);
try {
WebrtcMessage offerMsg = new WebrtcMessage();
offerMsg.setType(WebrtcMessageType.OFFER.getType());
offerMsg.setFromUserId(nextItem.getFromUserId());
offerMsg.setToUserId(toUserId);
offerMsg.setData(nextItem.getOfferData());
CALL_BIND_MAP.put(toUserId, nextItem.getFromUserId());
forwardMessage(offerMsg, toUserId);
updateWaitQueueIndex(waitQueue, toUserId);
log.info("【队列调度成功】呼叫方【{}】的 Offer 消息已转发给被叫方【{}】,剩余排队人数:{}", nextItem.getFromUserId(), toUserId, waitQueue.size());
} catch (Exception e) {
log.error("【队列调度失败】转发 Offer 消息给被叫方【{}】异常,排队项:{}", toUserId, nextItem, e);
processNextWaitItem(toUserId);
}
if (waitQueue.isEmpty()) {
CALL_WAIT_QUEUE_MAP.remove(toUserId);
} else {
CALL_WAIT_QUEUE_MAP.put(toUserId, waitQueue);
}
}
private void updateWaitQueueIndex(List<CallWaitItem> waitQueue, String toUserId) {
log.debug("【更新排队索引】被叫方【{}】,待更新排队项数量:{}", toUserId, waitQueue.size());
for (int i = 0; i < waitQueue.size(); i++) {
CallWaitItem item = waitQueue.get(i);
item.setQueueIndex(i + 1);
try {
sendQueueUpdateMsg(getUserSession(item.getFromUserId()), toUserId, item.getQueueIndex(), waitQueue.size());
log.debug("【排队状态更新】已通知用户【{}】,当前排队位置:第{}位,总人数:{}", item.getFromUserId(), item.getQueueIndex(), waitQueue.size());
} catch (Exception e) {
log.error("【排队状态更新失败】无法推送消息给排队用户【{}】", item.getFromUserId(), e);
}
}
}
private boolean isUserOnline(String userId) {
WebSocketSession session = ONLINE_SESSIONS.get(userId);
boolean isOnline = session != null && session.isOpen();
log.debug("【用户在线判断】用户【{}】,在线状态:{}", userId, isOnline);
return isOnline;
}
private boolean isUserBusy(String userId) {
boolean isBusy = CALL_BIND_MAP.containsKey(userId);
log.debug("【用户忙线判断】用户【{}】,忙线状态:{}", userId, isBusy);
return isBusy;
}
private void forwardMessage(WebrtcMessage message, String targetId) throws Exception {
WebSocketSession targetSession = ONLINE_SESSIONS.get(targetId);
if (targetSession != null && targetSession.isOpen()) {
String msgJson = OBJECT_MAPPER.writeValueAsString(message);
targetSession.sendMessage(new TextMessage(msgJson));
log.debug("【消息转发成功】消息类型:{},转发至用户【{}】,消息内容:{}", message.getType(), targetId, msgJson);
return;
}
log.warn("【消息转发失败】目标用户【{}】未在线或会话已关闭", targetId);
}
private String getUserIdFromSession(WebSocketSession session) {
try {
String query = session.getUri().getQuery();
if (query == null || query.isEmpty()) {
log.warn("【获取用户 ID 失败】会话 URL 无查询参数,会话 ID:{}", session.getId());
return null;
}
for (String param : query.split("&")) {
String[] keyValue = param.split("=", 2);
if (keyValue.length == 2 && "userId".equals(keyValue[0])) {
String userId = keyValue[1];
log.debug("【提取用户 ID 成功】会话 ID:{},提取到用户 ID:{}", session.getId(), userId);
return userId;
}
}
log.warn("【提取用户 ID 失败】查询参数中无 userId 字段,会话 ID:{},查询参数:{}", session.getId(), query);
return null;
} catch (Exception e) {
log.error("【提取用户 ID 异常】会话 ID:{}", session.getId(), e);
return null;
}
}
private WebSocketSession getUserSession(String userId) {
WebSocketSession session = ONLINE_SESSIONS.getOrDefault(userId, null);
log.debug("【获取用户会话】用户【{}】,会话是否存在:{}", userId, session != null);
return session;
}
private void sendErrorMsg(WebSocketSession session, String errorMsg) throws Exception {
if (session == null || !session.isOpen()) {
log.warn("【发送错误消息失败】会话已关闭或不存在,错误消息:{}", errorMsg);
return;
}
WebrtcMessage errorMessage = new WebrtcMessage();
errorMessage.setType(WebrtcMessageType.ERROR.getType());
errorMessage.setData(errorMsg);
String msgJson = OBJECT_MAPPER.writeValueAsString(errorMessage);
session.sendMessage(new TextMessage(msgJson));
log.debug("【错误消息发送成功】会话 ID:{},错误消息:{}", session.getId(), errorMsg);
}
private void sendQueueUpdateMsg(WebSocketSession session, String toUserId, int queueIndex, int totalCount) throws Exception {
if (session == null || !session.isOpen()) {
log.warn("【发送排队状态消息失败】会话已关闭或不存在,被叫方【{}】,排队位置:{}", toUserId, queueIndex);
return;
}
WebrtcMessage queueMsg = new WebrtcMessage();
queueMsg.setType(WebrtcMessageType.QUEUE_UPDATE.getType());
queueMsg.setToUserId(toUserId);
String queueData = String.format("{\"queueIndex\":%d,\"totalCount\":%d}", queueIndex, totalCount);
queueMsg.setData(queueData);
String msgJson = OBJECT_MAPPER.writeValueAsString(queueMsg);
session.sendMessage(new TextMessage(msgJson));
log.debug("【排队状态消息发送成功】会话 ID:{},被叫方【{}】,排队位置:第{}位,总人数:{}", session.getId(), toUserId, queueIndex, totalCount);
}
private void sendQueueTimeoutMsg(WebSocketSession session) throws Exception {
if (session == null || !session.isOpen()) {
log.warn("【发送排队超时消息失败】会话已关闭或不存在");
return;
}
WebrtcMessage timeoutMsg = new WebrtcMessage();
timeoutMsg.setType(WebrtcMessageType.QUEUE_TIMEOUT.getType());
timeoutMsg.setData("排队超时,已退出队列");
String msgJson = OBJECT_MAPPER.writeValueAsString(timeoutMsg);
session.sendMessage(new TextMessage(msgJson));
log.debug("【排队超时消息发送成功】会话 ID:{}", session.getId());
}
private void sendOfflineNotifyMsg(String targetId, String offlineUserId) throws Exception {
WebrtcMessage offlineMsg = new WebrtcMessage();
offlineMsg.setType(WebrtcMessageType.OFFLINE_NOTIFY.getType());
offlineMsg.setData("对方【" + offlineUserId + "】已掉线,通话已结束");
forwardMessage(offlineMsg, targetId);
log.debug("【对方离线通知发送成功】接收方【{}】,离线用户【{}】", targetId, offlineUserId);
}
private void cleanCallBind(String userId1, String userId2) {
CALL_BIND_MAP.remove(userId1);
CALL_BIND_MAP.remove(userId2);
log.debug("【清理通话绑定】已清理用户【{}】和【{}】的通话绑定关系", userId1, userId2);
}
private void cleanUserRelatedData(String userId, boolean notifyPartner) throws Exception {
log.info("【清理用户相关数据】开始清理用户【{}】的所有相关数据,是否通知通话对象:{}", userId, notifyPartner);
ONLINE_SESSIONS.remove(userId);
String callPartnerId = CALL_BIND_MAP.get(userId);
if (callPartnerId != null && notifyPartner) {
sendOfflineNotifyMsg(callPartnerId, userId);
cleanCallBind(userId, callPartnerId);
processNextWaitItem(callPartnerId);
}
CALL_WAIT_QUEUE_MAP.remove(userId);
for (List<CallWaitItem> waitQueue : CALL_WAIT_QUEUE_MAP.values()) {
waitQueue.removeIf(item -> item.getFromUserId().equals(userId));
}
log.info("【清理用户相关数据完成】用户【{}】的所有数据已清理完毕", userId);
}
@Scheduled(fixedRate = 10 * 1000)
private void cleanExpiredWaitItems() {
if (!ENABLE_QUEUE) {
return;
}
long currentTime = System.currentTimeMillis();
for (Map.Entry<String, List<CallWaitItem>> entry : CALL_WAIT_QUEUE_MAP.entrySet()) {
String toUserId = entry.getKey();
List<CallWaitItem> waitQueue = entry.getValue();
List<CallWaitItem> expiredItems = waitQueue.stream()
.filter(item -> (currentTime - item.getQueueTime()) > QUEUE_TIMEOUT_MS)
.collect(Collectors.toList());
for (CallWaitItem expiredItem : expiredItems) {
waitQueue.remove(expiredItem);
try {
sendQueueTimeoutMsg(getUserSession(expiredItem.getFromUserId()));
log.debug("【定时任务 - 清理过期排队项】清理,当前时间戳:{},待处理队列数量:{}", currentTime, CALL_WAIT_QUEUE_MAP.size());
} catch (Exception e) {
log.error("【定时任务 - 清理过期排队项】推送超时提示给用户【{}】失败", expiredItem.getFromUserId(), e);
}
log.info("【定时任务 - 清理过期排队项】已清理用户【{}】呼叫【{}】的过期排队项(排队超时)", expiredItem.getFromUserId(), toUserId);
}
updateWaitQueueIndex(waitQueue, toUserId);
if (waitQueue.isEmpty()) {
CALL_WAIT_QUEUE_MAP.remove(toUserId);
}
}
}
@Scheduled(fixedRate = 10 * 1000)
private void cleanInvalidSessions() {
for (String userId : ONLINE_SESSIONS.keySet()) {
WebSocketSession session = ONLINE_SESSIONS.get(userId);
if (session == null || !session.isOpen()) {
try {
cleanUserRelatedData(userId, false);
} catch (Exception e) {
log.error("【定时任务 - 清理无效会话】清理用户【{}】数据失败", userId, e);
}
log.info("【定时任务 - 清理无效会话】已清理用户【{}】的无效 WebSocket 会话", userId);
} else {
boolean isSessionValid = sendPingWithTimeout(session);
if (!isSessionValid) {
log.info(String.format("【定时任务 - 清理无效会话】用户【%s】的 WebSocket 会话 ping 超时,判定为无效并清理", userId));
ONLINE_SESSIONS.remove(userId);
if (session.isOpen()) {
try {
session.close(CloseStatus.GOING_AWAY.withReason("会话超时,已自动关闭"));
} catch (Exception ex) {
log.error("【定时任务 - 清理无效会话】关闭 WebSocket 无效会话失败:用户 ID【{}】", userId, ex);
}
}
}
}
}
}
private boolean sendPingWithTimeout(WebSocketSession session) {
FutureTask<Boolean> pingTask = new FutureTask<>(() -> {
try {
session.sendMessage(new TextMessage("{\"type\":\"ping\"}"));
return true;
} catch (Exception e) {
return false;
}
});
Thread pingThread = new Thread(pingTask, "WebSocket-Ping-Thread");
pingThread.start();
try {
return pingTask.get(PING_TIMEOUT_MS, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
pingTask.cancel(true);
return false;
} catch (InterruptedException | ExecutionException e) {
log.warn(String.format("【定时任务 - 清理无效会话】ping 操作出现异常:%s", e.getMessage()));
return false;
}
}
}
4、添加 WebSocket 配置类
@Configuration
@EnableWebSocket
@EnableScheduling
public class WebSocketConfig implements WebSocketConfigurer {
@Resource
private WebrtcSignalingHandler webrtcSignalingHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry
.addHandler(webrtcSignalingHandler, "/webrtc")
.setAllowedOrigins("*");
}
@Bean(name = "customWebSocketTaskScheduler")
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("websocket-scheduler-");
scheduler.setAwaitTerminationSeconds(10);
scheduler.setWaitForTasksToCompleteOnShutdown(true);
return scheduler;
}
}
5、启动类增加注解@EnableScheduling
@SpringBootApplication
@EnableScheduling
public class WebrtcServerApplication {
private static Logger log = LoggerFactory.getLogger(WebrtcServerApplication.class);
public static void main(String[] args) {
SpringApplication.run(WebrtcServerApplication.class, args);
log.info("========================================================================");
log.info("=========================WebRTC 信令服务器启动成功==========================");
log.info("--------WebSocket 访问端点:ws://[IP]:[Port]/webrtc?userId=[用户 ID]");
log.info("========================================================================");
}
}
6、测试连接
使用 Apipost 或其他工具创建 WebSocket 连接,连接地址(根据实际配置填写):ws://[IP]:[Port]/webrtc?userId=[用户 ID]
二、HTML 页面
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>WebRTC 视频对讲</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Microsoft YaHei", sans-serif; }
.container { width: 90%; max-width: 1200px; margin: 20px auto; }
.input-area, .queue-area, .call-area { margin-bottom: 20px; padding: 15px; border: 1px solid #eee; border-radius: 8px; }
.queue-area, .call-area { display: none; }
.video-area { display: flex; gap: 20px; flex-wrap: wrap; }
.video-box { flex: 1; min-width: 320px; }
video { width: 100%; height: 360px; border: 1px solid #ccc; border-radius: 8px; background-color: #000; }
button { padding: 8px 16px; margin: 5px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
.btn-call { background-color: #4CAF50; color: white; }
.btn-hangup, .btn-reject { background-color: #f44336; color: white; }
.btn-answer { background-color: #2196F3; color: white; }
input { padding: 8px; margin: 5px; border: 1px solid #ccc; border-radius: 4px; width: 200px; }
.tip, .queue-tip { color: #666; font-size: 12px; margin-top: 10px; }
.queue-highlight { color: #ff9800; font-weight: bold; }
</style>
</head>
<body>
<div>
<div>
<h3>基础配置</h3>
<label>自身用户 ID:</label>
<input type="text" placeholder="输入自己的 ID" required>
<button onclick="initWebSocket()">初始化连接</button>
<br>
<label>要呼叫的用户 ID:</label>
<input type="text" placeholder="输入对方的 ID">
<button onclick="callUser()">呼叫</button>
<button onclick="hangupCall()">挂断/取消呼叫</button>
<p>提示:先输入自身 ID 并初始化连接,再输入对方 ID 进行呼叫</p>
</div>
<div>
<h3>排队状态</h3>
<p>你正在呼叫 <span></span></p>
<p>当前排队位置:第 <span>0</span> 位</p>
<p>队列总人数:<span>0</span> 人</p>
<p>提示:排队超时 5 分钟将自动退出,被叫方空闲后将自动为你转发请求</p>
</div>
<div>
<h3>来电提醒</h3>
<p>来自 <span></span> 的呼叫</p>
<button onclick="answerCall()">接听</button>
<button onclick="rejectCall()">拒接</button>
</div>
<div>
<div>
<h4>本地视频(静音)</h4>
<video autoplay muted playsinline></video>
</div>
<div>
<h4>远程视频</h4>
<video autoplay playsinline></video>
</div>
</div>
</div>
<script src="js/jquery-3.5.1.min.js"></script>
<script>
var webSocket = null;
var peerConnection = null;
var localStream = null;
var remoteStream = null;
var currentCallerId = null;
var isCallValid = false;
var isCalling = false;
var pendingIceCandidates = [];
var webrtcServerUrl = "ws://192.168.1.190:9999/webrtc";
var RTC_CONFIG = {iceServers: []};
window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || .;
. = . || . || .;
navigator. = navigator. || {};
(!navigator..) {
navigator.. = () {
getUserMedia = navigator. || navigator.;
(!getUserMedia) {
.( ());
}
(() {
getUserMedia.(navigator, constraints, resolve, reject);
});
};
}
() {
fromUserId = $().().();
(!fromUserId) {
();
.();
;
}
(webSocket && webSocket. === .) {
.();
webSocket.();
}
wsUrl = webrtcServerUrl + + fromUserId;
.(, wsUrl);
{
webSocket = (wsUrl);
webSocket. = () {
.();
();
$().();
};
webSocket. = () {
.(, event);
$().();
$().();
$().();
.(, event., , event.);
();
};
webSocket. = () {
.(, error);
();
};
webSocket. = () {
.(, event.);
{
msg = .(event.);
.(, msg);
(msg);
} (e) {
.(, event., , e);
();
}
};
} (e) {
.(, e);
();
}
}
() {
msgType = msg.;
fromUserId = msg.;
data = msg.;
( == data) {
;
}
.(, msgType, , fromUserId, , data);
(msgType) {
:
(fromUserId, data);
;
:
(data);
;
:
(data);
;
:
(fromUserId);
;
:
(fromUserId, data);
;
:
(data);
;
:
(data);
;
:
(data);
;
:
(data);
;
:
.(, msgType);
}
}
() {
.(, fromUserId, , data);
(peerConnection) {
();
.();
;
}
currentCallerId = fromUserId;
$().(fromUserId);
$().();
.();
(() {
();
offer = ({ : , : data });
peerConnection.(offer).(() {
.();
(pendingIceCandidates. > ) {
.(, pendingIceCandidates.);
pendingIceCandidates.(() {
(cacheData);
});
pendingIceCandidates = [];
}
}).(() {
.(, error);
();
();
});
});
}
() {
.(, data);
(!peerConnection) {
.();
;
}
answer = ({ : , : data });
peerConnection.(answer).(() {
.();
isCalling = ;
$().();
}).(() {
.(, error);
();
isCalling = ;
();
});
}
() {
.(, data);
(!peerConnection) {
.(, pendingIceCandidates. + );
pendingIceCandidates.(data);
;
}
{
iceCandidateData = .(data);
iceCandidateCopy = .(.(iceCandidateData));
(iceCandidateCopy.) {
iceCandidateCopy. = iceCandidateCopy.;
iceCandidateCopy.;
}
iceCandidateCopy.;
iceCandidateCopy.;
(!iceCandidateCopy.) {
.();
;
}
iceCandidate = (iceCandidateCopy);
peerConnection.(iceCandidate).(() {
.(, error);
});
.();
} (e) {
.(, e);
}
}
() {
rejectMsg = + fromUserId + ;
.( + rejectMsg);
(rejectMsg);
();
}
() {
(data.()!=- || fromUserId==currentCallerId) {
leaveMsg = + fromUserId + ;
.( + leaveMsg);
(leaveMsg);
();
} {
leaveMsg = + fromUserId + ;
.( + leaveMsg);
(leaveMsg);
}
}
() {
.(, data);
(isCallValid) {
isCallValid = ;
();
(data);
}
}
() {
.(, data);
{
queueInfo = .(data);
$().(queueInfo.);
$().(queueInfo.);
$().($().().());
$().();
.( + queueInfo. + + queueInfo. + );
} (e) {
.(, e);
}
}
() {
.( + data);
(data);
$().();
();
}
() {
.( + data);
(data);
();
}
() {
mediaConstraints = {
: { : { : , : }, : { : , : }, : { : , : }, : },
: { : { : , : }, : { : , : }, : { : , : }, : { : , : } }
};
.(, mediaConstraints);
navigator..(mediaConstraints).(() {
localStream = stream;
$()[]. = stream;
.(, stream.().);
( callback === ) {
();
}
}).(() {
.(, error);
(error. === ) {
.();
(callback);
} (error. === ) {
();
} (error. === ) {
();
} {
( + error.);
}
});
}
() {
mediaConstraints = { : , : { : , : } };
.(, mediaConstraints);
navigator..(mediaConstraints).(() {
localStream = stream;
$()[]. = stream;
.(, stream.().);
( callback === ) {
();
}
}).(() {
.(, error);
( + error.);
});
}
() {
(peerConnection) {
.();
peerConnection.();
}
peerConnection = ();
.();
(localStream) {
localStream.().(() {
peerConnection.(track, localStream);
.(, track., , track.);
});
}
peerConnection. = () {
(event.) {
.(, event.);
iceCandidateCopy = .(.(event.));
iceCandidateCopy. = iceCandidateCopy.;
iceCandidateCopy.;
({ : , : .(iceCandidateCopy) });
} {
.();
}
};
peerConnection. = () {
.(, event.., , event..);
(!remoteStream) {
remoteStream = ();
.();
}
track = event.;
(!remoteStream.().( t. === track.)) {
remoteStream.(track);
.();
}
$()[]. = remoteStream;
$()[].().(() {
.(, error);
});
};
peerConnection. = () {
(!peerConnection) {
;
}
connState = peerConnection.;
.(, connState);
(connState === || connState === ) {
.();
();
}
};
}
() {
(!webSocket || webSocket. !== .) {
();
.(, webSocket ? webSocket. : );
;
}
toUserId = $().().();
(msg. !== && !toUserId && !currentCallerId) {
();
.();
;
}
fullMsg = { : msg., : $().().(), : toUserId || currentCallerId, : msg. };
{
jsonMsg = .(fullMsg);
webSocket.(jsonMsg);
.(, msg., , fullMsg., , jsonMsg);
} (e) {
.(, e);
();
}
}
() {
toUserId = $().().();
(!toUserId) {
();
.();
;
}
.(, toUserId);
(() {
();
isCallValid = ;
isCalling = ;
offerOptions = { : , : };
.(, offerOptions);
peerConnection.(offerOptions).(() {
peerConnection.(offer);
}).(() {
({ : , : peerConnection.. });
$().();
$().();
.();
}).(() {
.(, error);
();
isCalling = ;
();
});
});
}
() {
(!peerConnection || !currentCallerId) {
();
.();
;
}
.(, currentCallerId, );
isCallValid = ;
peerConnection.().(() {
peerConnection.(answer);
}).(() {
({ : , : peerConnection.. });
$().();
$().();
$().(currentCallerId);
$().();
.();
}).(() {
.(, error);
();
});
}
() {
(currentCallerId && webSocket && webSocket. === .) {
.(, currentCallerId, );
({ : , : });
}
();
();
.();
}
() {
(isCalling) {
.(, $().().());
(webSocket && webSocket. === .) {
({ : , : });
}
();
();
} {
.(, $().().());
(webSocket && webSocket. === .) {
({ : , : });
}
();
();
}
.();
}
() {
.();
isCallValid = ;
isCalling = ;
(localStream) {
localStream.().(() {
track.();
.(, track.);
});
localStream = ;
$()[]. = ;
.();
}
(peerConnection) {
peerConnection. = ;
peerConnection. = ;
peerConnection. = ;
peerConnection.();
peerConnection = ;
.();
}
(remoteStream) {
remoteStream.().(() {
track.();
.(, track.);
});
remoteStream = ;
$()[]. = ;
.();
}
$().();
$().();
$().();
$().();
$().();
$().();
.();
currentCallerId = ;
pendingIceCandidates = [];
.();
}
</script>
</body>
</html>
三、配置浏览器
浏览器获取本地摄像头/麦克风需要有限制,地址需要满足任意一项:localhost、127.0.0.1、https,内网环境下想通过 IP 来访问需要配置 unsafely-treat-insecure-origin-as-secure
谷歌、Edge、360 浏览器
谷歌 / 360:chrome://flags/
Edge:edge://flags/
- 启用【Insecure origins treated as secure】项,在输入框中添加需要对讲的系统地址【http://服务器 IP:项目端口】,多个地址之间使用英文【,】分隔
火狐浏览器
- 搜索框中输入【insecure】
找到【media.devices.insecure.enabled】和【media.getusermedia.insecure.enabled】,点击后面的切换按钮,把值改为 true 后刷新页面
四、测试视频通话
创建了 4 个浏览器分别登录不同的 id,用来模拟一对一呼叫和多对一呼叫的情况
一对一呼叫
多对一呼叫(排队功能)
排队列表
用户 3 取消呼叫,用户 4 的排队顺位提升
用户 2 取消呼叫,用户 4 的呼叫请求传递到用户 1 并接听成功
window
mozRTCSessionDescription
window
RTCIceCandidate
window
RTCIceCandidate
window
webkitRTCIceCandidate
window
mozRTCIceCandidate
mediaDevices
mediaDevices
if
mediaDevices
getUserMedia
mediaDevices
getUserMedia
function
constraints
var
webkitGetUserMedia
mozGetUserMedia
if
return
Promise
reject
new
Error
"当前浏览器不支持获取媒体设备(无 getUserMedia 实现)"
return
new
Promise
function
resolve, reject
call
function
initWebSocket
var
"#fromUserId"
val
trim
if
alert
"请先输入有效的自身用户 ID!"
console
error
"[WebSocket 初始化失败] 原因:未输入有效的自身用户 ID"
return
if
readyState
WebSocket
OPEN
console
log
"[WebSocket] 检测到已有活跃连接,先关闭旧连接"
close
var
"?userId="
console
log
"[WebSocket] 开始创建连接,目标地址:"
try
new
WebSocket
onopen
function
console
log
"[WebSocket] 连接成功!状态:OPEN"
alert
"WebSocket 连接成功!可以开始呼叫对方了"
"#callBtn"
show
onclose
function
event
console
log
"[WebSocket] 连接已关闭,关闭详情:"
"#callBtn"
hide
"#hangupBtn"
hide
"#queueArea"
hide
console
log
"[WebSocket] 关闭状态码:"
code
",关闭原因:"
reason
alert
"WebSocket 连接已关闭,请重新初始化连接"
onerror
function
error
console
error
"[WebSocket] 连接错误,错误详情:"
alert
"WebSocket 连接失败!请检查信令服务器是否启动"
onmessage
function
event
console
log
"[WebSocket] 收到服务器消息,原始数据:"
data
try
var
JSON
parse
data
console
log
"[WebSocket] 消息解析成功,消息内容:"
handleMessage
catch
console
error
"[WebSocket] 解析信令消息失败,原始数据:"
data
",错误详情:"
alert
"收到无效的信令消息,无法处理(格式非合法 JSON)"
catch
console
error
"[WebSocket] 创建连接实例失败,错误详情:"
alert
"无法创建 WebSocket 连接,请检查浏览器是否支持 WebSocket"
function
handleMessage
msg
var
type
var
fromUserId
var
data
if
"{\"type\":\"ping\"}"
return
console
log
"[信令消息分发] 收到消息类型:"
",发送方:"
",消息数据:"
switch
case
"offer"
handleOfferMessage
break
case
"answer"
handleAnswerMessage
break
case
"iceCandidate"
handleIceCandidateMessage
break
case
"reject"
handleRejectMessage
break
case
"leave"
handleLeaveMessage
break
case
"error"
handleErrorMessage
break
case
"queueUpdate"
handleQueueUpdateMessage
break
case
"queueTimeout"
handleQueueTimeoutMessage
break
case
"offlineNotify"
handleOfflineNotifyMessage
break
default
console
warn
"[信令消息分发] 收到未知类型的消息,无法处理,消息类型:"
function
handleOfferMessage
fromUserId, data
console
log
"[Offer 消息处理] 收到来自"
"的呼叫,Offer 数据:"
if
alert
"已有正在进行的通话,无法接收新的呼叫"
console
warn
"[Offer 消息处理] 处理失败,原因:已有活跃的 PeerConnection(存在正在进行的通话)"
return
"#callerUserId"
text
"#callArea"
show
console
log
"[Offer 消息处理] 已显示来电提醒界面,等待用户接听/拒接"
getLocalMediaStream
function
createPeerConnection
var
new
RTCSessionDescription
type
"offer"
sdp
setRemoteDescription
then
function
console
log
"[Offer 消息处理] 设置远程 Offer 描述成功"
if
length
0
console
log
"[Offer 消息处理] 开始处理待排队的 ICE 候选,数量:"
length
forEach
function
cacheData
handleIceCandidateMessage
catch
function
error
console
error
"[Offer 消息处理] 设置远程 Offer 描述失败,错误详情:"
alert
"处理来电失败,无法建立连接"
resetCallState
function
handleAnswerMessage
data
console
log
"[Answer 消息处理] 收到对方接听消息,Answer 数据:"
if
console
warn
"[Answer 消息处理] 处理失败,原因:PeerConnection 未初始化(无活跃通话)"
return
var
new
RTCSessionDescription
type
"answer"
sdp
setRemoteDescription
then
function
console
log
"[Answer 消息处理] 设置远程 Answer 描述成功,SDP 协商完成,开始建立 P2P 媒体连接"
false
"#queueArea"
hide
catch
function
error
console
error
"[Answer 消息处理] 设置远程 Answer 描述失败,错误详情:"
alert
"对方接听失败,无法建立通话"
false
resetCallState
function
handleIceCandidateMessage
data
console
log
"[ICE 候选处理] 收到对方 ICE 候选消息,候选数据:"
if
console
log
"[ICE 候选处理] PeerConnection 未就绪,将候选加入待处理队列,当前队列长度:"
length
1
push
return
try
var
JSON
parse
var
JSON
parse
JSON
stringify
if
sdp
candidate
sdp
delete
sdp
delete
adapterType
delete
serverUrl
if
candidate
console
warn
"[ICE 候选处理] 候选数据无效,无 candidate 字段,跳过处理"
return
var
new
RTCIceCandidate
addIceCandidate
catch
function
error
console
error
"[ICE 候选处理] 添加远程 ICE 候选失败,错误详情:"
console
log
"[ICE 候选处理] 远程 ICE 候选添加成功"
catch
console
error
"[ICE 候选处理] 解析或添加 ICE 候选失败,错误详情:"
function
handleRejectMessage
fromUserId
var
"对方【"
"】已拒接通话"
console
log
"[拒接消息处理] "
alert
resetCallState
function
handleLeaveMessage
fromUserId, data
if
indexOf
"已挂断"
1
var
"对方【"
"】已挂断通话"
console
log
"[挂断消息处理] "
alert
resetCallState
else
var
"对方【"
"】已取消通话"
console
log
"[取消消息处理] "
alert
function
handleErrorMessage
data
console
error
"[错误消息处理] 收到服务器错误提示:"
if
false
resetCallState
alert
function
handleQueueUpdateMessage
data
console
log
"[排队状态处理] 收到排队状态更新消息,排队数据:"
try
var
JSON
parse
"#queueIndex"
text
queueIndex
"#queueTotal"
text
totalCount
"#queueToUserId"
text
"#toUserId"
val
trim
"#queueArea"
show
console
log
"[排队状态处理] 排队界面更新完成,当前位置:第"
queueIndex
"位,总人数:"
totalCount
"人"
catch
console
error
"[排队状态处理] 解析排队状态失败,错误详情:"
function
handleQueueTimeoutMessage
data
console
log
"[排队超时处理] "
alert
"#queueArea"
hide
resetCallState
function
handleOfflineNotifyMessage
data
console
log
"[离线通知处理] "
alert
resetCallState
function
getLocalMediaStream
callback
var
video
width
ideal
640
max
640
height
ideal
480
max
480
frameRate
ideal
30
max
30
aspectRatio
1.3333333333
audio
echoCancellation
ideal
true
exact
true
noiseSuppression
ideal
true
exact
true
autoGainControl
ideal
true
exact
true
highpassFilter
ideal
true
exact
true
console
log
"[媒体流获取] 开始获取本地高清媒体流,约束条件:"
mediaDevices
getUserMedia
then
function
stream
"#localVideo"
0
srcObject
console
log
"[媒体流获取] 本地高清媒体流获取成功,流包含轨道数:"
getTracks
length
if
typeof
"function"
callback
catch
function
error
console
error
"[媒体流获取] 本地高清媒体流获取失败,错误详情:"
if
name
"OverconstrainedError"
console
log
"[媒体流获取] 高清约束条件不满足,切换为降级模式获取媒体流"
getLocalMediaStreamDefault
else
if
name
"NotAllowedError"
alert
"请授予浏览器麦克风/摄像头的访问权限!"
else
if
name
"NotFoundError"
alert
"未检测到麦克风/摄像头设备,请检查设备是否连接!"
else
alert
"获取媒体流失败:"
message
function
getLocalMediaStreamDefault
callback
var
video
true
audio
echoCancellation
true
noiseSuppression
true
console
log
"[媒体流获取] 开始降级模式获取本地媒体流,约束条件:"
mediaDevices
getUserMedia
then
function
stream
"#localVideo"
0
srcObject
console
log
"[媒体流获取] 降级模式本地媒体流获取成功,流包含轨道数:"
getTracks
length
if
typeof
"function"
callback
catch
function
error
console
error
"[媒体流获取] 降级模式本地媒体流获取失败,错误详情:"
alert
"获取媒体流失败:"
message
function
createPeerConnection
if
console
log
"[PeerConnection] 检测到已有旧实例,先关闭旧连接"
close
new
RTCPeerConnection
RTC_CONFIG
console
log
"[PeerConnection] 新实例创建成功"
if
getTracks
forEach
function
track
addTrack
console
log
"[PeerConnection] 已添加本地媒体轨道,轨道类型:"
kind
",轨道 ID:"
id
onicecandidate
function
event
if
candidate
console
log
"[PeerConnection] 本地收集到 ICE 候选,准备发送给对方:"
candidate
var
JSON
parse
JSON
stringify
candidate
sdp
candidate
delete
candidate
sendMessage
type
"iceCandidate"
data
JSON
stringify
else
console
log
"[PeerConnection] ICE 候选收集完成,无更多候选数据"
ontrack
function
event
console
log
"[PeerConnection] 收到远程媒体轨道,轨道类型:"
track
kind
",轨道 ID:"
track
id
if
new
MediaStream
console
log
"[PeerConnection] 远程媒体流初始化成功"
var
track
if
getTracks
some
t =>
id
id
addTrack
console
log
"[PeerConnection] 已添加远程媒体轨道到远程流"
"#remoteVideo"
0
srcObject
"#remoteVideo"
0
play
catch
function
error
console
warn
"[PeerConnection] 远程视频播放触发失败(可能已自动播放),错误详情:"
onconnectionstatechange
function
if
return
var
connectionState
console
log
"[PeerConnection] 连接状态变化,当前状态:"
if
"closed"
"failed"
console
log
"[PeerConnection] 连接已关闭或失败,自动重置通话状态"
resetCallState
function
sendMessage
msg
if
readyState
WebSocket
OPEN
alert
"WebSocket 连接已断开,无法发送消息"
console
error
"[消息发送失败] 原因:WebSocket 连接未处于 OPEN 状态,当前状态:"
readyState
"未初始化"
return
var
"#toUserId"
val
trim
if
type
"leave"
alert
"请先输入目标用户 ID"
console
error
"[消息发送失败] 原因:未指定目标用户 ID"
return
var
type
type
fromUserId
"#fromUserId"
val
trim
toUserId
data
data
try
var
JSON
stringify
send
console
log
"[消息发送成功] 消息类型:"
type
",目标用户:"
toUserId
",发送内容:"
catch
console
error
"[消息发送失败] 错误详情:"
alert
"无法发送消息,请检查网络连接"
function
callUser
var
"#toUserId"
val
trim
if
alert
"请先输入目标用户 ID"
console
error
"[发起呼叫失败] 原因:未输入目标用户 ID"
return
console
log
"[发起呼叫] 开始呼叫目标用户:"
getLocalMediaStream
function
createPeerConnection
true
true
var
offerToReceiveAudio
true
offerToReceiveVideo
true
console
log
"[发起呼叫] 开始创建 Offer,选项:"
createOffer
then
function
offer
return
setLocalDescription
then
function
sendMessage
type
"offer"
data
localDescription
sdp
"#callBtn"
hide
"#hangupBtn"
show
console
log
"[发起呼叫] Offer 消息发送成功,等待对方接听"
catch
function
error
console
error
"[发起呼叫] 生成或发送 Offer 失败,错误详情:"
alert
"呼叫失败,无法建立连接"
false
resetCallState
function
answerCall
if
alert
"无有效来电,无法接听"
console
error
"[接听呼叫失败] 原因:PeerConnection 未初始化或无有效来电方 ID"
return
console
log
"[接听呼叫] 开始接听来自"
"的呼叫"
true
createAnswer
then
function
answer
return
setLocalDescription
then
function
sendMessage
type
"answer"
data
localDescription
sdp
"#callArea"
hide
"#callBtn"
hide
"#toUserId"
val
"#hangupBtn"
show
console
log
"[接听呼叫] Answer 消息发送成功,等待建立媒体连接"
catch
function
error
console
error
"[接听呼叫] 生成或发送 Answer 失败,错误详情:"
alert
"接听失败,无法建立通话"
function
rejectCall
if
readyState
WebSocket
OPEN
console
log
"[拒接呼叫] 开始拒接来自"
"的呼叫"
sendMessage
type
"reject"
data
"reject"
resetCallState
alert
"已拒接来电"
console
log
"[拒接呼叫] 已完成拒接操作,通话状态已重置"
function
hangupCall
if
console
log
"[挂断/取消呼叫] 执行取消呼叫逻辑,当前呼叫目标:"
"#toUserId"
val
trim
if
readyState
WebSocket
OPEN
sendMessage
type
"leave"
data
"cancelCall"
resetCallState
alert
"已取消呼叫"
else
console
log
"[挂断/取消呼叫] 执行挂断通话逻辑,当前通话对象:"
"#toUserId"
val
trim
if
readyState
WebSocket
OPEN
sendMessage
type
"leave"
data
"hangup"
resetCallState
alert
"已挂断通话"
console
log
"[挂断/取消呼叫] 已完成操作,通话状态已重置"
function
resetCallState
console
log
"[重置通话状态] 开始清理所有通话相关资源和界面状态"
false
false
if
getTracks
forEach
function
track
stop
console
log
"[重置通话状态] 已停止本地媒体轨道,轨道类型:"
kind
null
"#localVideo"
0
srcObject
null
console
log
"[重置通话状态] 本地媒体流已清理,本地视频已重置"
if
onicecandidate
null
ontrack
null
onconnectionstatechange
null
close
null
console
log
"[重置通话状态] PeerConnection 已关闭并清理"
if
getTracks
forEach
function
track
stop
console
log
"[重置通话状态] 已停止远程媒体轨道,轨道类型:"
kind
null
"#remoteVideo"
0
srcObject
null
console
log
"[重置通话状态] 远程媒体流已清理,远程视频已重置"
"#hangupBtn"
hide
"#callArea"
hide
"#queueArea"
hide
"#callBtn"
show
"#toUserId"
val
""
"#callerUserId"
text
""
console
log
"[重置通话状态] 界面状态已恢复初始"
null
console
log
"[重置通话状态] 所有通话相关资源清理完成"
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online