wssocket.js,用于创建websocket,发送到后端信令服务器,并接收信令服务器转发过来的消息。
let closeCallBack = null;
let isConnect = false; //连接标识 避免重复连接
let rec = null;
let lastConnectTime = new Date(); // 最后一次连接时间
let socketTask = null;
// 生成随机ID
function generateUserId() {
return Math.floor(Math.random() * 9000 + 1000).toString();
}
let connect = (wsurl, token) => {
if (isConnect) {
return;
}
lastConnectTime = new Date();
socketTask = uni.connectSocket({
url: wsurl,
protocols: [token],
success: (res) => {
console.log("websocket连接成功");
},
fail: (e) => {
console.log(e);
console.log("websocket连接失败,10s后重连");
setTimeout(() => {
connect();
}, 10000)
}
});
socketTask.onOpen((res) => {
isConnect = true;
console.log('WebSocket 连接已建立');
})
socketTask.onMessage((res) => {
console.log('收到 WebSocket 消息:', event.data);
const message = JSON.parse(event.data);
handleSignalingMessage(message);
})
socketTask.onClose((res) => {
console.log('WebSocket 连接已关闭,3秒后重试');
reconnect(wsurl, token)
})
socketTask.onError((e) => {
console.log("ws错误:", e)
close();
isConnect = false;
closeCallBack && closeCallBack({
code: 1006
});
})
}
function handleSignalingMessage(message) {
console.log('收到信令消息:', message);
switch (message.type) {
case 'call-invitation':
console.log('收到通话邀请 from:', message.from);
uni.navigateTo({
url: "/subpages/videocallrev/videocallrev?currentCallId=" + message.from
})
break;
case 'call-accepted':
console.log('对方同意通话邀请 from:', message.from);
uni.navigateTo({
url: `/subpages/videocall/videocallh5?currentCallId=${message.from}&isCaller=true`
})
break;
case 'call-rejected':
console.log('对方拒绝通话邀请from:', message.from);
handleCallRejected(message.from);
break;
case 'offer':
uni.$emit('handleOffer', {
fromId: message.from,
data: message.data
})
break;
case 'answer':
uni.$emit('handleAnswer', {
fromId: message.from,
data: message.data
})
break;
case 'ice':
uni.$emit('handleIceCandidate', {
fromId: message.from,
data: message.data
})
break;
case 'call-ended':
uni.$emit('handleCallEnded', {
fromId: message.from
})
break;
}
}
//定义重连函数
let reconnect = (wsurl, accessToken) => {
console.log("尝试重新连接");
if (isConnect) {
return;
}
// 延迟10秒重连 避免过多次过频繁请求重连
let timeDiff = new Date().getTime() - lastConnectTime.getTime()
let delay = timeDiff < 10000 ? 10000 - timeDiff : 0;
rec && clearTimeout(rec);
rec = setTimeout(function() {
connect(wsurl, accessToken);
}, delay);
};
//设置关闭连接
let close = (code) => {
if (!isConnect) {
return;
}
socketTask.close({
code: code,
complete: (res) => {
console.log("关闭websocket连接");
isConnect = false;
},
fail: (e) => {
console.log("关闭websocket连接失败", e);
}
});
};
let sendMessage = (message) => {
socketTask.send({
data: JSON.stringify(message)
})
}
// 将方法暴露出去
export {
connect,
reconnect,
close,
sendMessage
}
SignalingWebSocket.java,接收前端websocket连接,接收websocket的send方法传递的数据,解析数据后转发给接收方。
package org.jeecg.modules.webrtc;
import com.alibaba.fastjson.JSON;
import jakarta.websocket.*;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
@Slf4j
@Component
@ServerEndpoint("/signaling/{userId}")
public class SignalingWebSocket {
// 存储所有在线用户的WebSocket会话
private static final ConcurrentHashMap<String, Session> users = new ConcurrentHashMap<>();
// 存储用户ID和名称的映射
private static final ConcurrentHashMap<String, String> userNames = new ConcurrentHashMap<>();
// 添加消息发送锁
private static final ConcurrentHashMap<Session, ReentrantLock> sessionLocks = new ConcurrentHashMap<>();
private String userId;
@OnOpen
public void onOpen(Session session, @PathParam(value = "userId") String userId) {
users.put(userId,session);
sessionLocks.put(session, new ReentrantLock());
log.info("新的WebSocket连接: {}", session.getId());
}
@OnMessage
public void onMessage(String message, Session session) {
try {
WebRTCMessage webRTCMessage = JSON.parseObject(message, WebRTCMessage.class);
log.info("收到消息 from: {}, to: {}, type: {}",
webRTCMessage.getFrom(),
webRTCMessage.getTo(),
webRTCMessage.getType());
switch (webRTCMessage.getType()) {
case "join":
handleJoin(webRTCMessage, session);
break;
case "chat":
log.info("转发聊天消息: {} -> {}", webRTCMessage.getFrom(), webRTCMessage.getTo());
forwardMessage(webRTCMessage);
break;
case "whiteboard":
log.info("转发白板数据: {} -> {}, action: {}",
webRTCMessage.getFrom(),
webRTCMessage.getTo(),
webRTCMessage.getAction());
forwardMessage(webRTCMessage);
break;
case "call-invitation":
case "call-accepted":
case "call-rejected":
case "offer":
case "answer":
case "ice":
case "call-ended":
forwardMessage(webRTCMessage);
break;
case "leave":
handleLeave(webRTCMessage);
break;
default:
log.warn("未知的消息类型: {}", webRTCMessage.getType());
}
} catch (Exception e) {
log.error("处理消息时发生错误: {}", message, e);
}
}
@OnClose
public void onClose(Session session) {
if (userId != null) {
users.remove(userId);
userNames.remove(userId);
// 通知其他用户该用户已离开
broadcastUserList();
}
sessionLocks.remove(session);
log.info("WebSocket连接关闭: {}", session.getId());
}
@OnError
public void onError(Session session, Throwable error) {
log.error("WebSocket错误", error);
}
private void handleJoin(WebRTCMessage message, Session session) {
userId = message.getFrom();
users.put(userId, session);
userNames.put(userId, message.getName());
log.info("用户加入: {}, 当前在线用户: {}", userId, userNames.keySet());
// 发送当前在线用户列表给新用户
sendUserList(session);
// 广播新用户加入消息给其他用户
broadcastUserList();
}
private void handleLeave(WebRTCMessage message) {
String leavingUserId = message.getFrom();
Session session = users.get(leavingUserId);
if (session != null) {
users.remove(leavingUserId);
userNames.remove(leavingUserId);
broadcastUserList();
}
}
private void forwardMessage(WebRTCMessage message) {
Session targetSession = users.get(message.getTo());
if (targetSession != null && targetSession.isOpen()) {
log.info("正在转发消息 type: {} from: {} to: {}",
message.getType(),
message.getFrom(),
message.getTo());
sendMessageToSession(targetSession, message);
} else {
log.warn("目标用户不在线或会话已关闭: {}", message.getTo());
}
}
private void sendUserList(Session session) {
WebRTCMessage message = new WebRTCMessage();
message.setType("users");
message.setData(userNames);
sendMessageToSession(session, message);
}
private void broadcastUserList() {
WebRTCMessage message = new WebRTCMessage();
message.setType("users");
message.setData(userNames);
users.values().forEach(session -> {
sendMessageToSession(session, message);
});
}
private void sendMessageToSession(Session session, WebRTCMessage message) {
if (session == null || !session.isOpen()) {
return;
}
ReentrantLock lock = sessionLocks.get(session);
if (lock != null) {
lock.lock();
try {
String messageJson = JSON.toJSONString(message);
session.getBasicRemote().sendText(messageJson);
} catch (IOException e) {
log.error("发送消息失败", e);
} finally {
lock.unlock();
}
}
}
}
sendvideocall.vue,呼叫方发起视频通话邀请。
<template>
<view class="content">
<image :src="getFile(friendInfo.headImage)" style="height: 100vh;" mode="heightFix"></image>
<view class="ftbg">
<view style="margin-top: 280rpx;">
<u-avatar :src="getFile(friendInfo.headImage)" size="100" style="margin: 10rpx;"
mode="square"></u-avatar>
</view>
<view style="margin-top: 10rpx;color: white;font-size: 28rpx;">
{{ friendInfo.nickName }}
</view>
<view style="margin-top: 130rpx;color: white;">
正在等待对方回应...
</view>
</view>
<view
style="position: fixed;bottom: 100rpx;left: 0rpx;width: 100%;display: flex;justify-content: center;align-items: center;">
<image @click="cancel" src="/static/images/icon_guad.png"
style="width: 120rpx;height: 120rpx;border-radius: 60rpx;" mode=""></image>
</view>
</view>
</template>
<script>
import {
mapGetters,
} from 'vuex';
export default {
data() {
return {
id: '',
mid: '',
friend: "",
currentCallId: '',
userId: "",
}
},
computed: {
...mapGetters(['findFriend']),
friendInfo() {
return this.findFriend(this.currentCallId);
},
},
onBackPress(options) {
if (options.from && options.from == 'backbutton') {
return true
}
return false
},
onLoad({
currentCallId
}) {
this.currentCallId = currentCallId
this.userId = this.$store.state.userInfo.id
this.sendCallInvitation()
},
methods: {
sendCallInvitation() {
console.log('发送通话邀请给:', this.currentCallId);
this.$socket.sendMessage({
type: 'call-invitation',
from: this.userId,
to: this.currentCallId,
});
},
cancel: async function() {
this.$socket.sendMessage({
type: 'call-ended',
from: this.userId,
to: this.currentCallId
});
uni.navigateBack()
},
}
}
</script>
<style scoped>
.content {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(to bottom, #666, #222);
}
.ftbg {
width: 100%;
height: 100vh;
top: 0rpx;
left: 0rpx;
background: #00000099;
backdrop-filter: blur(20px);
position: fixed;
display: flex;
flex-direction: column;
align-items: center;
}
</style>
videocallrev.vue,接收方收到通话邀请后跳转的页面,在此页面可以同意或拒绝通话。
<template>
<view class="content">
<image :src="getFile(friendInfo.headImage)" style="height: 100vh;" mode="heightFix"></image>
<view class="ftbg">
<view style="margin-top: 280rpx;">
<u-avatar :src="getFile(friendInfo.headImage)" size="100" style="margin: 10rpx;"
mode="square"></u-avatar>
</view>
<view style="margin-top: 10rpx;color: white;font-size: 28rpx;">
{{friendInfo.nickName}}
</view>
<view style="margin-top: 130rpx;color: white;">
邀请你视频通话
</view>
</view>
<view
style="position: fixed;bottom: 100rpx;left: 0rpx;width: 100%;display: flex;justify-content: center;align-items: center;">
<image @click="cancel" src="/static/images/icon_guad.png"
style="width: 120rpx;height: 120rpx;border-radius: 60rpx;" mode=""></image>
<image @click="agree" src="/static/images/icon_hup.png"
style="width: 120rpx;height: 120rpx;border-radius: 60rpx;margin-left: 240rpx;" mode=""></image>
</view>
</view>
</template>
<script>
import {
mapGetters
} from 'vuex';
export default {
data() {
return {
currentCallId: '',
mid: '',
message: ''
}
},
computed: {
...mapGetters(['findFriend']),
friendInfo() {
return this.findFriend(this.currentCallId);
},
},
onBackPress(options) {
if (options.from && options.from == 'backbutton') {
return true
}
return false
},
onLoad({
currentCallId
}) {
this.currentCallId = currentCallId
},
methods: {
cancel: async function() {
const ret = await this.$u.put(`chat/chat/videocall/refuse/${this.mid}`)
pub("stopmusic", {})
this.$store.commit(`common/setStatus`, "normal")
uni.navigateBack()
},
agree: async function() {
// #ifdef APP
uni.redirectTo({
url: `/subpages/videocall/videocall?currentCallId=${this.currentCallId}&&isCaller=false`
})
// #endif
// #ifdef H5
uni.redirectTo({
url: `/subpages/videocall/videocallh5?currentCallId=${this.currentCallId}&&isCaller=false`
})
// #endif
},
}
}
</script>
<style scoped>
.content {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(to bottom, #666, #222);
}
.ftbg {
width: 100%;
height: 100vh;
top: 0rpx;
left: 0rpx;
background: #00000099;
backdrop-filter: blur(20px);
position: fixed;
display: flex;
flex-direction: column;
align-items: center;
}
</style>
videocallh5.vue,接收方同意通话邀请后,呼叫方和接收方都跳转的页面。
<template>
<view class="content">
<view style="" class="topvg">
<view style="width: 100%;display: flex;justify-content: space-between;padding: 50rpx;">
<view style="display: flex;align-items: center;">
<view @click="changecamera" class="btnitem" style="">
<image src="/static/images/qqhsxt.png" style="width: 60rpx;" mode="widthFix"></image>
</view>
<view @click="showself = !showself" class="btnitem" style="margin-left: 20rpx;">
<image src="/static/images/sshow.png" style="width: 60rpx;" mode="widthFix"></image>
</view>
<view v-if="!uservddata.audio" class="btnitem" style="margin-left: 20rpx;">
<image src="/static/images/dfab.png" style="width: 60rpx;" mode="widthFix"></image>
</view>
</view>
<view style="font-size: 28rpx;color: #bec7df;">
</view>
<view style="width: 60rpx;">
</view>
</view>
</view>
<view v-show="showself"
style="width: 240rpx;height: 400rpx; position: fixed;right: 40rpx;top: 130rpx;z-index: 101;border-radius: 16rpx;">
<bgyx-video-item :uid="$store.state.userInfo.id" :name="$store.state.userInfo.realname" radius="16rpx"
:id="`bgyx_video_1`" :src="myvddata.stream" status="play" :video="myvddata.video"
:audio="myvddata.audio" :muted="true" />
</view>
<view style="width: 100%;display: flex;flex-wrap: wrap;flex: 1;overflow-y: hidden;">
<bgyx-video-item :uid="id" :name="name" :id="`bgyx_video_0`" :src="uservddata.stream" status="play"
:video="uservddata.video" :audio="uservddata.audio" />
</view>
<view style="" class="btmvg">
<view style="display: flex;width: 100%;align-items: center;justify-content: center;margin-bottom: 60rpx;">
<view @click="changeaudio(false)" v-if="myvddata.audio" class="btnitem" style="">
<image src="/static/images/mmic.png" style="width: 60rpx;" mode="widthFix"></image>
</view>
<view @click="changeaudio(true)" v-if="!myvddata.audio" class="btnitem" style="background: #ff5e5e66;">
<image src="/static/images/mmic.png" style="width: 60rpx;" mode="widthFix"></image>
</view>
<view @click="exit" class="btnitem"
style="margin-left: 80rpx;background: #ff5e5e;width: 140rpx;height: 140rpx;">
<image src="/static/images/hhdown.png" style="width: 70rpx;" mode="widthFix"></image>
</view>
<view @click="changevideo(false)" v-if="myvddata.video" class="btnitem" style="margin-left: 80rpx;">
<image src="/static/images/ccamera.png" style="width: 60rpx;" mode="widthFix"></image>
</view>
<view @click="changevideo(true)" v-if="!myvddata.video" class="btnitem"
style="margin-left: 80rpx;background: #ff5e5e66;">
<image src="/static/images/ccamera.png" style="width: 60rpx;" mode="widthFix"></image>
</view>
</view>
</view>
</view>
</template>
<script>
import {
mapGetters
} from 'vuex';
import BgyxVideoItem from '@/subpages/components/im/bgyx-video-item.vue'
export default {
components: {
BgyxVideoItem
},
data() {
return {
id: '',
name: '',
src: '',
showself: true,
myvddata: '',
uservddata: '',
audio: true,
video: true,
isCaller: false,
userId: "",
currentCallId: "",
peerConnection: null,
configuration: {
iceServers: [{
urls: [
'stun:stun.l.google.com:19302',
'stun:stun1.l.google.com:19302'
]
}],
iceTransportPolicy: 'all',
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require',
iceCandidatePoolSize: 0
}
}
},
computed: {
...mapGetters(['findFriend']),
friendInfo() {
return this.findFriend(this.currentCallId);
},
},
onBackPress(options) {
if (options.from && options.from == 'backbutton') {
return true
}
return false
},
onLoad({
currentCallId,
isCaller
}) {
this.currentCallId = currentCallId
this.userId = this.$store.state.userInfo.id
this.isCaller = JSON.parse(isCaller)
this.name = this.friendInfo.nickName
if (this.isCaller) { //呼叫者
this.publishStream({
video: true,
audio: true,
videoType: 'face'
})
} else { //被呼叫者
this.$socket.sendMessage({
type: 'call-accepted',
from: this.userId,
to: this.currentCallId
});
}
//被呼叫者接收offer
uni.$on('handleOffer', (data) => {
this.handleOffer(data.fromId, data.data)
})
//呼叫者接收被呼叫者创建的应答
uni.$on('handleAnswer', (data) => {
this.handleAnswer(data.fromId, data.data)
})
//双方交换ice
uni.$on('handleIceCandidate', (data) => {
this.handleIceCandidate(data.fromId, data.data)
})
uni.$on('handleCallEnded', (data) => {
this.handleCallEnded(data.fromId)
})
},
methods: {
exit() {
this.$socket.sendMessage({
type: "call-ended",
from: this.userId,
to: this.currentCallId,
})
uni.navigateBack()
},
handleCallEnded(fromId) {
console.log("对方结束了通话 from:", fromId)
if (fromId == this.currentCallId) {
uni.showToast({
title: "对方结束了通话",
icon: "none"
})
}
},
// 添加处理 answer 的函数
async handleAnswer(fromId, answer) {
console.log('收到应答 from:', fromId);
const pc = this.peerConnection;
if (pc) {
await pc.setRemoteDescription(new RTCSessionDescription(answer));
}
},
// 添加处理 ICE candidate 的函数
async handleIceCandidate(fromId, candidate) {
console.log('收到candidate from:', fromId);
const pc = this.peerConnection;
if (pc) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
},
// 修改 handleOffer 函数
async handleOffer(fromId, offer) {
console.log('收到offer from:', fromId);
const pc = new RTCPeerConnection(this.configuration);
this.peerConnection = pc
const localStream = await this.getStreamLoc({
audio: true,
video: true,
})
localStream.getTracks().forEach(function(track) {
pc.addTrack(track);
});
pc.onicecandidate = async (event) => {
if (event.candidate) { // 移除 host 限制
console.log('handleOffer发送 ICE 候选者:', event.candidate);
this.$socket.sendMessage({
type: 'ice',
from: this.userId,
to: fromId,
data: event.candidate
});
}
};
let remoteStream = new MediaStream();
pc.ontrack = function(event) {
remoteStream.addTrack(event.track);
};
console.log('设置远程offer描述:', offer);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
console.log('创建answer:', answer);
await pc.setLocalDescription(answer);
const mydata = {
id: this.userId,
pc,
stream: localStream,
audio: true,
video: true,
};
this.myvddata = mydata
const vdata = {
id: this.currentCallId,
pc,
stream: remoteStream,
audio: true,
video: true,
};
this.uservddata = vdata
this.$socket.sendMessage({
type: 'answer',
from: this.userId,
to: fromId,
data: answer
});
},
async publishStream(options) {
const pc = new RTCPeerConnection(this.configuration);
this.peerConnection = pc
const localStream = await this.getStreamLoc({
audio: true,
video: true,
})
localStream.getTracks().forEach(function(track) {
pc.addTrack(track);
});
let remoteStream = new MediaStream();
pc.ontrack = function(event) {
remoteStream.addTrack(event.track);
};
let offer = await pc.createOffer();
await pc.setLocalDescription(offer);
console.log('创建offer:', offer);
pc.onicecandidate = async (event) => {
if (event.candidate) { // 移除 host 限制
console.log('publishStream发送 ICE 候选者:', event.candidate);
this.$socket.sendMessage({
type: 'ice',
from: this.userId,
to: this.currentCallId,
data: event.candidate
});
}
};
const myvdata = {
id: this.userId,
pc,
stream: localStream,
audio: true,
video: true,
};
this.myvddata = myvdata
const uvdata = {
id: this.currentCallId,
pc,
stream: remoteStream,
audio: true,
video: true,
};
this.uservddata = uvdata
this.$socket.sendMessage({
type: 'offer',
from: this.userId,
to: this.currentCallId,
data: offer
});
},
getStreamLoc: async function(constraints) {
if (navigator.mediaDevices.getUserMedia) {
console.log('最新的标准API', navigator.mediaDevices.getUserMedia);
const rs = await navigator.mediaDevices.getUserMedia(constraints)
return rs
} else if (navigator.webkitGetUserMedia) {
console.log('webkit核心浏览器');
const rs = await navigator.webkitGetUserMedia(constraints)
return rs
} else if (navigator.mozGetUserMedia) {
console.log('firfox浏览器');
const rs = await navigator.mozGetUserMedia(constraints);
return rs
} else if (navigator.getUserMedia) {
console.log('旧版API');
const rs = await navigator.getUserMedia(constraints);
return rs
} else {
const rs = await navigator.mediaDevices.getUserMedia(constraints)
return rs
}
},
}
}
</script>
<style scoped>
.content {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100vh;
justify-content: space-between;
overflow-y: hidden;
}
.btmvg {
width: 100%;
display: flex;
height: 40%;
background: linear-gradient(to bottom, #33333300, #111111);
position: fixed;
left: 0rpx;
bottom: 0rpx;
color: white;
align-items: flex-end;
}
.topvg {
width: 100%;
display: flex;
height: 40%;
background: linear-gradient(to bottom, #111111, #33333300);
position: fixed;
left: 0rpx;
top: 0rpx;
color: white;
align-items: flex-start;
z-index: 99;
}
.btnitem {
width: 120rpx;
height: 120rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 160rpx;
background: #cfcfcf55;
}
</style>
效果展示:
一对一视频邀请:

一对一视频通话:

1416

被折叠的 条评论
为什么被折叠?



