|
@@ -0,0 +1,362 @@
|
|
|
+<template>
|
|
|
+
|
|
|
+ <div class="video-user-container">
|
|
|
+ <!-- 远程视频 -->
|
|
|
+ <video autoplay class="remote-video" ref="remoteVideoRef"></video>
|
|
|
+ <!-- 本地视频 -->
|
|
|
+ <video autoplay class="local-video" ref="localVideoRef"></video>
|
|
|
+
|
|
|
+ <!-- 挂断通话 -->
|
|
|
+ <svg class="leave-btn icon" t="1739368256646" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3572" width="40" height="40"><path d="M0 512a512 512 0 1 0 1024 0A512 512 0 1 0 0 512z" fill="#F75855" p-id="3573"></path><path d="M502.24 414.2c78.71 2.19 155.93 11.61 225.57 52.4 46.19 27.06 59.2 52.68 52.6 96.3-4.54 30-21.84 44.86-51.68 46.7-58.81 3.61-84.16-15.87-90.44-72.24-2.11-18.94-14-26.82-29.03-30.22-63.34-14.31-127.14-13.52-190.84-2.28-20.32 3.59-29.81 17.2-28.68 37.58 2.13 38.35-20.49 54.18-54.04 60.39-41.35 7.65-70-3.01-84.89-31.53-15.68-30.04-10.26-65.69 14.51-90.09 28.34-27.91 64.05-42.34 101.72-51.48 44.29-10.76 89.33-17.04 135.2-15.53z" fill="#FEFEFE" p-id="3574"></path></svg>
|
|
|
+ </div>
|
|
|
+
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+ import axios from 'axios';
|
|
|
+ import { inject, defineComponent, ref, onMounted, onUnmounted, watch, onBeforeUnmount } from 'vue';
|
|
|
+ export default defineComponent({
|
|
|
+ props: {
|
|
|
+ openVideo: Boolean,
|
|
|
+ selectChatUserRef: Object
|
|
|
+ },
|
|
|
+ setup(props) {
|
|
|
+ const socket = ref(null);
|
|
|
+ const messageQueue = ref([]); // 存储未发送的消息队列
|
|
|
+ const isConnected = ref(false);
|
|
|
+ const loginUser = ref(inject('loginUser'));
|
|
|
+ // 反转下角色,由接听人来发offer信息
|
|
|
+ const offerUser = ref(props.openVideo);
|
|
|
+ const selectChatUser = ref(props.selectChatUserRef);
|
|
|
+ const reconnectInterval = ref(5000); // 重连间隔时间,单位:毫秒
|
|
|
+ const heartbeatInterval = ref(30000); // 心跳间隔时间,单位:毫秒
|
|
|
+ // 本地音视频流
|
|
|
+ const remoteVideoRef = ref(null)
|
|
|
+ const localVideoRef = ref(null)
|
|
|
+ const localStream = ref(null)
|
|
|
+ // 远程音视频流
|
|
|
+ const remoteStream = ref(null)
|
|
|
+ // 配置ICE Server(用于网络穿透)
|
|
|
+ const iceServerConfiguration = {
|
|
|
+ "iceServers": [
|
|
|
+ {
|
|
|
+ "urls": "stun:stun.l.google.com:19302"
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ const peerConn = ref(null);
|
|
|
+ // 修改点 1:使用标准的 RTCPeerConnection
|
|
|
+ const PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
|
|
|
+ // 修改点 2:重构初始化本地流的逻辑
|
|
|
+ const initLocalStream = async () => {
|
|
|
+ if (peerConn.value == null && !offerUser.value) {
|
|
|
+ // 作为呼叫方呼叫
|
|
|
+ sendObjMessage({
|
|
|
+ type: "call",
|
|
|
+ userId: loginUser.value.userId,
|
|
|
+ targetUserId: selectChatUser.value.userId
|
|
|
+ });
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ // 获取本地媒体流
|
|
|
+ localStream.value = await navigator.mediaDevices.getUserMedia({
|
|
|
+ video: true,
|
|
|
+ audio: true
|
|
|
+ });
|
|
|
+ localVideoRef.value.srcObject = localStream.value;
|
|
|
+ // 初始化 PeerConnection
|
|
|
+ peerConn.value = new PeerConnection(iceServerConfiguration);
|
|
|
+ // 添加本地轨道(替代已废弃的 addStream)
|
|
|
+ localStream.value.getTracks().forEach(track => {
|
|
|
+ peerConn.value.addTrack(track, localStream.value);
|
|
|
+ });
|
|
|
+ // 修改点 3:使用现代 ontrack 事件监听
|
|
|
+ peerConn.value.ontrack = (event) => {
|
|
|
+ if (event.streams && event.streams.length > 0) {
|
|
|
+ remoteStream.value = event.streams[0];
|
|
|
+ remoteVideoRef.value.srcObject = remoteStream.value;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ // ICE 候选处理
|
|
|
+ peerConn.value.onicecandidate = (event) => {
|
|
|
+ if (event.candidate) {
|
|
|
+ sendObjMessage({
|
|
|
+ type: "candidate",
|
|
|
+ candidate: event.candidate,
|
|
|
+ userId: loginUser.value.userId,
|
|
|
+ targetUserId: selectChatUser.value.userId
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+ // 创建并发送 Offer
|
|
|
+ if (offerUser.value) {
|
|
|
+ const offer = await peerConn.value.createOffer();
|
|
|
+ await peerConn.value.setLocalDescription(offer);
|
|
|
+ sendObjMessage({
|
|
|
+ type: "offer",
|
|
|
+ offer: offer,
|
|
|
+ userId: loginUser.value.userId,
|
|
|
+ targetUserId: selectChatUser.value.userId
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('初始化本地流失败:', error);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 5:完善处理 Offer 的逻辑
|
|
|
+ const handleOffer = async (offer) => {
|
|
|
+ try {
|
|
|
+ if (!peerConn.value) {
|
|
|
+ // 初始化接收方的 PeerConnection
|
|
|
+ peerConn.value = new PeerConnection(iceServerConfiguration);
|
|
|
+ // 获取本地媒体流(接收方也需要本地流)
|
|
|
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
|
|
+ localVideoRef.value.srcObject = stream;
|
|
|
+ stream.getTracks().forEach(track => {
|
|
|
+ peerConn.value.addTrack(track, stream);
|
|
|
+ });
|
|
|
+ // 设置远程流监听
|
|
|
+ peerConn.value.ontrack = (event) => {
|
|
|
+ if (event.streams && event.streams.length > 0) {
|
|
|
+ remoteStream.value = event.streams[0];
|
|
|
+ remoteVideoRef.value.srcObject = remoteStream.value;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ // ICE 候选处理
|
|
|
+ peerConn.value.onicecandidate = (event) => {
|
|
|
+ if (event.candidate) {
|
|
|
+ sendObjMessage({
|
|
|
+ type: "candidate",
|
|
|
+ candidate: event.candidate,
|
|
|
+ userId: loginUser.value.userId,
|
|
|
+ targetUserId: selectChatUser.value.userId
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }
|
|
|
+ await peerConn.value.setRemoteDescription(new RTCSessionDescription(offer));
|
|
|
+ const answer = await peerConn.value.createAnswer();
|
|
|
+ await peerConn.value.setLocalDescription(answer);
|
|
|
+ sendObjMessage({
|
|
|
+ type: "answer",
|
|
|
+ answer: answer,
|
|
|
+ userId: loginUser.value.userId,
|
|
|
+ targetUserId: selectChatUser.value.userId
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ console.error("处理 Offer 失败:", error);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 完善处理 Answer 的逻辑
|
|
|
+ const handleAnswer = async (answer) => {
|
|
|
+ try {
|
|
|
+ if (!peerConn.value) {
|
|
|
+ console.error("PeerConnection 未初始化");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await peerConn.value.setRemoteDescription(new RTCSessionDescription(answer));
|
|
|
+ } catch (error) {
|
|
|
+ console.error("处理 Answer 失败:", error);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleCandidate = async (candidate) => {
|
|
|
+ try {
|
|
|
+ if (!peerConn.value) {
|
|
|
+ console.error("PeerConnection 未初始化");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (peerConn.value.iceConnectionState === 'closed') {
|
|
|
+ console.error("PeerConnection 已关闭,无法添加 ICE 候选");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!candidate || typeof candidate !== 'object' || !candidate.candidate || !candidate.sdpMid) {
|
|
|
+ console.error("Invalid ICE candidate:", candidate);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await peerConn.value.addIceCandidate(candidate);
|
|
|
+ console.log("成功添加 ICE 候选");
|
|
|
+ } catch (error) {
|
|
|
+ console.error("处理 ICE 候选失败:", error);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const connectWebSocket = () => {
|
|
|
+
|
|
|
+ socket.value = new WebSocket(`/connect-service/ws/p2p/video/PC_WEB_CHAT/` + loginUser.value.userId);
|
|
|
+ socket.value.onopen = () => {
|
|
|
+ console.log('WebSocket 已连接');
|
|
|
+ isConnected.value = true;
|
|
|
+ // 发送队列中的消息
|
|
|
+ messageQueue.value.forEach(message => socket.value.send(message));
|
|
|
+ messageQueue.value = [];
|
|
|
+ startHeartbeat();
|
|
|
+ // 调起本地摄像头,初始化音视频流
|
|
|
+ initLocalStream();
|
|
|
+ };
|
|
|
+
|
|
|
+ socket.value.onmessage = (event) => {
|
|
|
+ console.log('收到WS消息:', event.data);
|
|
|
+ const messageData = JSON.parse(event.data);
|
|
|
+ switch(messageData.type) {
|
|
|
+ case "offer":
|
|
|
+ handleOffer(messageData.offer);
|
|
|
+ break;
|
|
|
+ case "answer":
|
|
|
+ handleAnswer(messageData.answer);
|
|
|
+ break;
|
|
|
+ case "candidate" :
|
|
|
+ handleCandidate(messageData.candidate);
|
|
|
+ break;
|
|
|
+ case "leave" :
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ socket.value.onerror = (error) => {
|
|
|
+ console.error('WebSocket 错误:', error);
|
|
|
+ isConnected.value = false;
|
|
|
+ // 关闭当前连接
|
|
|
+ socket.value.close();
|
|
|
+ // 尝试重连
|
|
|
+ setTimeout(connectWebSocket, reconnectInterval.value);
|
|
|
+ };
|
|
|
+
|
|
|
+ socket.value.onclose = () => {
|
|
|
+ console.log('WebSocket 已关闭');
|
|
|
+ isConnected.value = false;
|
|
|
+ // 尝试重连
|
|
|
+ setTimeout(connectWebSocket, reconnectInterval.value);
|
|
|
+ };
|
|
|
+ };
|
|
|
+
|
|
|
+ const startHeartbeat = () => {
|
|
|
+ const heartbeat = () => {
|
|
|
+ if (isConnected.value) {
|
|
|
+ socket.value.send('ping'); // 发送心跳包
|
|
|
+ }
|
|
|
+ };
|
|
|
+ setInterval(heartbeat, heartbeatInterval.value);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 链接ws
|
|
|
+ connectWebSocket();
|
|
|
+
|
|
|
+
|
|
|
+ const sendObjMessage = (obj) => {
|
|
|
+ sendMessage(JSON.stringify(obj))
|
|
|
+ }
|
|
|
+ const sendMessage = (message) => {
|
|
|
+ if (isConnected.value) {
|
|
|
+ socket.value.send(message);
|
|
|
+ } else {
|
|
|
+ messageQueue.value.push(message);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const cleanup = () => {
|
|
|
+ // WebSocket 清理
|
|
|
+ if (socket.value) {
|
|
|
+ try {
|
|
|
+ if ([WebSocket.OPEN, WebSocket.CONNECTING].includes(socket.value.readyState)) {
|
|
|
+ socket.value.close()
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error('关闭 WebSocket 失败:', err)
|
|
|
+ } finally {
|
|
|
+ socket.value = null
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // WebRTC 清理
|
|
|
+ if (peerConn.value) {
|
|
|
+ try {
|
|
|
+ peerConn.value.close()
|
|
|
+ } catch (err) {
|
|
|
+ console.error('关闭 PeerConnection 失败:', err)
|
|
|
+ } finally {
|
|
|
+ peerConn.value = null
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 媒体流清理 (关键补充)
|
|
|
+ if (localStream.value) {
|
|
|
+ localStream.value.getTracks().forEach(track => {
|
|
|
+ track.stop() // 必须停止每个媒体轨道
|
|
|
+ })
|
|
|
+ localStream.value = null
|
|
|
+ }
|
|
|
+ if (remoteStream.value) {
|
|
|
+ remoteStream.value.getTracks().forEach(track => {
|
|
|
+ track.stop() // 必须停止每个媒体轨道
|
|
|
+ })
|
|
|
+ remoteStream.value = null
|
|
|
+ }
|
|
|
+ // 发送终止信令 (重要)
|
|
|
+ // TODO
|
|
|
+ }
|
|
|
+
|
|
|
+ onBeforeUnmount(() => {
|
|
|
+ cleanup();
|
|
|
+ }) ;
|
|
|
+ onMounted(() => {
|
|
|
+
|
|
|
+ });
|
|
|
+ onUnmounted(() => {
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ selectChatUser,
|
|
|
+ localVideoRef,
|
|
|
+ remoteVideoRef,
|
|
|
+ initLocalStream,
|
|
|
+ handleOffer,
|
|
|
+ handleAnswer,
|
|
|
+ handleCandidate,
|
|
|
+ cleanup
|
|
|
+ };
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+</script>
|
|
|
+
|
|
|
+<style>
|
|
|
+ .video-user-container {
|
|
|
+ position: absolute;
|
|
|
+ top: 50px;
|
|
|
+ left: 0px;
|
|
|
+ width: 100%;
|
|
|
+ height: 530px;
|
|
|
+ background-color: black;
|
|
|
+ }
|
|
|
+ .remote-video {
|
|
|
+ position: absolute;
|
|
|
+ right: 20px;
|
|
|
+ top: 20px;
|
|
|
+ width: 500px;
|
|
|
+ height: 380px;
|
|
|
+ }
|
|
|
+ .local-video {
|
|
|
+ position: absolute;
|
|
|
+ left: 20px;
|
|
|
+ top: 20px;
|
|
|
+ width: 500px;
|
|
|
+ height: 380px;
|
|
|
+ }
|
|
|
+ .video-title {
|
|
|
+ position: absolute;
|
|
|
+ left: 10px;
|
|
|
+ top: 15px;
|
|
|
+ font-size: 15px;
|
|
|
+ color: white;
|
|
|
+ }
|
|
|
+ .leave-btn {
|
|
|
+ position: absolute;
|
|
|
+ left: 48%;
|
|
|
+ bottom: 40px;
|
|
|
+ border: 2px solid rgb(239, 133, 133);
|
|
|
+ border-radius: 100px;
|
|
|
+ }
|
|
|
+</style>
|