liming 1 semana atrás
pai
commit
c8ae309d3c

+ 96 - 37
src/stores/modules/webrtcStore.js

@@ -1,6 +1,11 @@
 import { defineStore } from "pinia";
-import { MSG_TYPE, MESSAGE_TYPE_USER, MESSAGE_TYPE_GROUP } from "@/common/constant/msgType";
+import {
+  MSG_TYPE,
+  MESSAGE_TYPE_USER,
+  MESSAGE_TYPE_GROUP,
+} from "@/common/constant/msgType";
 import { useWebSocketStore } from "@/stores/modules/webSocketStore";
+import * as Constant from "@/common/constant/Constant";
 
 export const useWebRTCStore = defineStore("webrtc", {
   state: () => ({
@@ -9,13 +14,17 @@ export const useWebRTCStore = defineStore("webrtc", {
 
     // ICE 候选信息
     iceCandidates: [],
+    pendingIceCandidates: [], // 缓存未处理的候选
 
     // 连接状态
     connectionState: "disconnected",
 
     // 媒体流
-    localStream: null,
-    remoteStream: null,
+    localStream: null,  // 本地媒体流
+    remoteStream: null, // 远端媒体流
+
+    // 是否是发起方
+    isCaller: false,
 
     // 配置项
     config: {
@@ -24,11 +33,33 @@ export const useWebRTCStore = defineStore("webrtc", {
         // 可以添加更多 STUN/TURN 服务器
       ],
     },
-  }), 
+  }),
   actions: {
+    bindRemoteAudio() {
+      // 如果已经存在 audio 元素,先移除旧的
+      const oldAudio = document.getElementById("remote-audio");
+      if (oldAudio) {
+        oldAudio.remove();
+      }
+
+      // 创建新的 <audio> 元素
+      const audioElement = document.createElement("audio");
+      audioElement.id = "remote-audio";
+      audioElement.autoplay = true; // 自动播放
+      audioElement.muted = false;  // 取消静音
+      audioElement.controls = true; // 显示控制条(可选)
+      audioElement.srcObject = this.remoteStream;
+
+      // 添加到 DOM(可以放在任意位置,比如 body)
+      document.body.appendChild(audioElement);
+
+      console.log("✅ 远程音频已绑定到 <audio> 元素");
+    },
     // 初始化 WebRTC 连接
-    initConnection(MESSAGE_TYPE) {
+    initConnection(isCaller) {
       this.cleanup();
+      this.isCaller = isCaller;
+      
       const wsStore = useWebSocketStore();
       try {
         this.peerConnection = new RTCPeerConnection();
@@ -36,21 +67,14 @@ export const useWebRTCStore = defineStore("webrtc", {
         // 设置事件监听: 对等方收到ice信息后,通过调用 addIceCandidate 将接收的候选者信息传递给浏览器的ICE代理
         this.peerConnection.onicecandidate = (event) => {
           if (event.candidate) {
-            // if (MESSAGE_TYPE === MESSAGE_TYPE_USER) {
-            //   let candidate = {
-            //     type: "offer_ice",
-            //     iceCandidate: event.candidate,
-            //   };
-            //   wsStore.sendMessage({
-            //     contentType: MSG_TYPE.AUDIO_ONLINE,
-            //     type: "webrtc",
-            //     messageType: MESSAGE_TYPE,
-            //     content: JSON.stringify(candidate),
-            //   });
-            // }
-            // if (MESSAGE_TYPE === MESSAGE_TYPE_GROUP) {
-           
-            // }
+            let candidate = {
+              type: this.isCaller ? "offer_ice" : "answer_ice",
+              iceCandidate: event.candidate,
+            };
+            wsStore.sendMessage({
+              content: JSON.stringify(candidate),
+              type: Constant.MESSAGE_TRANS_TYPE,
+            });
             this.iceCandidates.push(event.candidate);
           }
         };
@@ -69,6 +93,21 @@ export const useWebRTCStore = defineStore("webrtc", {
           event.streams[0].getTracks().forEach((track) => {
             this.remoteStream.addTrack(track);
           });
+          // 绑定音频
+          this.bindRemoteAudio()
+        };
+
+        // 监听 ICE 连接状态(关键修复!)
+        this.peerConnection.oniceconnectionstatechange = () => {
+          const state = this.peerConnection.iceConnectionState;
+          console.log("ICE 连接状态:", state);
+
+          if (state === "connected") {
+            console.log("✅ P2P 连接成功,可以开始语音通话!");
+          } else if (state === "failed") {
+            console.error("❌ ICE 连接失败,尝试重启...");
+            this.restartICE();
+          }
         };
 
         console.log("WebRTC 连接初始化成功");
@@ -123,36 +162,54 @@ export const useWebRTCStore = defineStore("webrtc", {
       }
     },
 
-    // 设置远程 Description
+    // 设置远程描述后处理缓存
     async setRemoteDescription(desc) {
-      if (!this.peerConnection) {
-        throw new Error("WebRTC 连接未初始化");
-      }
-
-      try {
-       
-        // 设置远程描述
-        await this.peerConnection.setRemoteDescription(desc);
-      } catch (error) {
-        console.error("设置远程 Description 失败:", error);
-        throw error;
+      await this.peerConnection.setRemoteDescription(desc);
+      // 处理缓存的候选
+      while (this.pendingIceCandidates.length > 0) {
+        const candidate = this.pendingIceCandidates.shift();
+        await this.peerConnection
+          .addIceCandidate(candidate)
+          .catch((e) => console.error(e));
       }
     },
 
     // 添加 ICE 候选
     async addIceCandidate(candidate) {
-      if (!this.peerConnection) {
-        throw new Error("WebRTC 连接未初始化");
+      if (!candidate) {
+        console.warn("收到空的 ICE 候选");
+        return;
       }
+
+      // 如果是候选结束信号(candidate:null)
+      if (candidate.candidate === "") {
+        console.log("ICE 候选收集完成");
+        return;
+      }
+
       try {
+        // 确保 PeerConnection 和远程描述已就绪
+        if (!this.peerConnection) {
+          this.pendingIceCandidates.push(candidate);
+          return;
+        }
+
+        // Answer 方必须等待远程描述
+        if (!this.peerConnection.remoteDescription && !this.isCaller) {
+          this.pendingIceCandidates.push(candidate);
+          return;
+        }
+
         await this.peerConnection.addIceCandidate(candidate);
+        console.log("✅ 成功添加 ICE 候选:", candidate.candidate);
       } catch (error) {
-        console.error("添加 ICE 候选失败:", error);
-        throw error;
+        console.error("❌ 添加 ICE 候选失败:", error);
+        // 失败后重试缓存
+        this.pendingIceCandidates.push(candidate);
       }
     },
 
-    // 清理资源
+    // 清理资源:挂断
     cleanup() {
       if (this.peerConnection) {
         this.peerConnection.close();
@@ -169,3 +226,5 @@ export const useWebRTCStore = defineStore("webrtc", {
     },
   },
 });
+
+

+ 38 - 43
src/views/im/chat/index.vue

@@ -45,8 +45,9 @@
                 v-else-if="item.contentType === MSG_TYPE.IMAGE"
               >
                 <van-image
-                  :src="item?.localUrl ||  IM_PATH + item.url"
-                  style="max-width: 120px; border-radius: 8px" @click="previewImage(item)"
+                  :src="item?.localUrl || IM_PATH + item.url"
+                  style="max-width: 120px; border-radius: 8px"
+                  @click="previewImage(item)"
                 />
               </div>
 
@@ -183,11 +184,11 @@ import { useWalletStore } from "@/stores/modules/walletStore.js";
 import { Keyboard } from "@capacitor/keyboard";
 import { Capacitor } from "@capacitor/core";
 import { MSG_TYPE, MESSAGE_TYPE_USER } from "@/common/constant/msgType";
-import  messageAudio from "@/views/im/components/messageAudio/index.vue";
-import { showToast,showImagePreview } from 'vant';
-import {useWebRTCStore} from "@/stores/modules/webrtcStore"; 
+import messageAudio from "@/views/im/components/messageAudio/index.vue";
+import { showToast, showImagePreview } from "vant";
+import { useWebRTCStore } from "@/stores/modules/webrtcStore";
 import * as Constant from "@/common/constant/Constant";
-import VoiceCallModal from '@/views/im/components/VoiceCallModal/index.vue';
+import VoiceCallModal from "@/views/im/components/VoiceCallModal/index.vue";
 
 const IM_PATH = import.meta.env.VITE_IM_PATH_FIlE;
 // 路由 & store
@@ -212,8 +213,8 @@ const showVoiceCall = ref(false);
 
 // 示例用户
 const currentUser = ref({
-  avatar: 'https://example.com/avatar.jpg',
-  nickname: '张三'
+  avatar: "https://example.com/avatar.jpg",
+  nickname: "张三",
 });
 
 const isSender = (toUsername) => {
@@ -269,18 +270,19 @@ const toggleAppBox = async (type) => {
 // 预览图片
 const previewImage = (item) => {
   const imageList = wsStore.messages
-    .filter(m => m.contentType === MSG_TYPE.IMAGE)
-    .map(m => m.localUrl || IM_PATH + m.url);
+    .filter((m) => m.contentType === MSG_TYPE.IMAGE)
+    .map((m) => m.localUrl || IM_PATH + m.url);
 
-  const index = imageList.findIndex(url => url === (item.localUrl || IM_PATH + item.url));
+  const index = imageList.findIndex(
+    (url) => url === (item.localUrl || IM_PATH + item.url)
+  );
 
   showImagePreview({
     images: imageList,
-    startPosition: index
+    startPosition: index,
   });
 };
 
-
 const onFocus = () => {
   // 隐藏所有面板
   showEmoji.value = false;
@@ -391,7 +393,6 @@ const sendMessage = () => {
     content: text.value,
     contentType: MSG_TYPE.TEXT, // 1: 文本消息
     messageType: MESSAGE_TYPE_USER, // 1: 单聊天
-   
   };
   wsStore.sendMessage(message);
   text.value = "";
@@ -415,44 +416,38 @@ const afterRead = async (file) => {
 const beforeRead = (file) => {
   const realFile = file.file || file;
   const type = realFile.type;
-  const name = realFile.name || '';
+  const name = realFile.name || "";
 
-  if (type === 'image/svg+xml' || name.endsWith('.svg')) {
-    showToast('不支持上传 SVG 格式的图片');
+  if (type === "image/svg+xml" || name.endsWith(".svg")) {
+    showToast("不支持上传 SVG 格式的图片");
     return false;
   }
   return true;
 };
 
-
-// 初始化webrtc连接
-rtcStore.initConnection(MESSAGE_TYPE_USER);
 //  创建呼叫:开启语音电话
 const startAudioOnline = async () => {
   showVoiceCall.value = true;
-  // 获取本地媒体流并添加到连接
-  try {
-    const stream = await navigator.mediaDevices.getUserMedia({
-      audio: true,
-      video: true,
+  // 初始化webrtc连接
+  rtcStore.initConnection(true);
+
+  navigator.mediaDevices
+    .getUserMedia({ audio: true })
+    .then((stream) => {
+      rtcStore.addLocalStream(stream);
+      return rtcStore.createOffer();
+    })
+    .then((offer) => {
+      // 发送offer
+      wsStore.sendMessage({
+        contentType: Constant.AUDIO_ONLINE, // 消息内容类型
+        content: JSON.stringify(offer),
+        type: Constant.MESSAGE_TRANS_TYPE,
+      });
+    })
+    .catch((error) => {
+      console.error("发起呼叫失败:", error);
     });
-    rtcStore.addLocalStream(stream);
-  } catch (error) {
-    console.error("获取媒体流失败:", error);
-  }
-  //  创建呼叫
-  try { 
-    const offer = await rtcStore.createOffer();
-    // 发送 offer 给对等端
-    let data = {
-      contentType: Constant.AUDIO_ONLINE, // 消息内容类型
-      content: JSON.stringify(offer),
-      type: Constant.MESSAGE_TRANS_TYPE, // 消息传输类型
-    };
-    wsStore.sendMessage(data);
-  } catch (error) {
-    console.error("发起呼叫失败:", error);
-  }
 };
 
 const handleHangup = () => {
@@ -643,7 +638,7 @@ const goDetail = () => router.push("detail");
             font-weight: 400;
             font-size: 15px;
           }
-          .img-message{
+          .img-message {
             margin-top: 8px;
           }
         }

+ 46 - 20
src/views/im/hook/messagesHook.js

@@ -87,19 +87,25 @@ export const handleMessageHook = (message, state) => {
       const answerSdp = new RTCSessionDescription({ type, sdp });
       rtcStore.setRemoteDescription(answerSdp);
     }
-    // 添加answer ice
-    if (type === "answer_ice") {
-      rtcStore.addIceCandidate(iceCandidate);
-    }
-    // 
-    if (type === "offer_ice") {
-      rtcStore.addIceCandidate(iceCandidate);
+   // 处理 ICE 候选(统一处理offer_ice和answer_ice)
+    if (type.endsWith("_ice")) {
+      const candidate = new RTCIceCandidate(iceCandidate);
+      rtcStore.addIceCandidate(candidate)
+        .catch(error => console.error("添加ICE候选失败:", error));
+      return;
     }
+
+
     // 响应对端offer
     if (type === "offer") {
       // 检查媒体权限是否开启
-
       // let preview = null;
+
+       if (!rtcStore.peerConnection) {
+        rtcStore.initConnection(false); // false表示是Answer方
+      }
+
+      
       let video = false;
       if (message.contentType === Constant.VIDEO_ONLINE) {
         // preview = document.getElementById("localVideoReceiver");
@@ -111,24 +117,44 @@ export const handleMessageHook = (message, state) => {
         // preview = document.getElementById("audioPhone");
         // 音频电话
       }
-      // 获取媒体流
+      
+
       navigator.mediaDevices
-        .getUserMedia({
-          audio: true,
-          video: video,
-        })
-        .then(async (stream) => {
-          // preview.srcObject = stream;
+        .getUserMedia({ audio: true, video: video })
+        .then((stream) => {
           rtcStore.addLocalStream(stream);
-          const answer = await rtcStore.createOffer();
-          // 发送 offer 给对等端
-          let data = {
+          const offer = new RTCSessionDescription({ type, sdp });
+          return rtcStore.setRemoteDescription(offer);
+        })
+        .then(() => rtcStore.createAnswer())
+        .then((answer) => {
+          state.sendMessage({
             content: JSON.stringify(answer),
             type: Constant.MESSAGE_TRANS_TYPE,
             messageType: message.contentType,
-          };
-          state.sendMessage(data);
+          });
         });
+
+      // // 获取媒体流
+      // navigator.mediaDevices
+      //   .getUserMedia({
+      //     audio: true,
+      //     video: video,
+      //   })
+      //   .then(async (stream) => {
+      //     // preview.srcObject = stream;
+      //     rtcStore.addLocalStream(stream);
+      //     const offerSdp = new RTCSessionDescription({ type, sdp });
+      //     await rtcStore.setRemoteDescription(offerSdp)
+      //     const answer = await rtcStore.createOffer();
+      //     // 发送 offer 给对等端
+      //     let data = {
+      //       content: JSON.stringify(answer),
+      //       type: Constant.MESSAGE_TRANS_TYPE,
+      //       messageType: message.contentType,
+      //     };
+      //     state.sendMessage(data);
+      //   });
     }
   }
 };