index.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. <template>
  2. <div v-if="rtcStore.imSate.videoCallModal" class="weixin-call-modal">
  3. <!-- 音频通话 -->
  4. <div class="caller-info" v-if="rtcStore.streamType == 'audio'">
  5. <img
  6. class="avatar"
  7. :src="formatAvatarUrl(rtcStore.imSate.callAvatar || defaultAvatar)"
  8. alt="头像"
  9. />
  10. <div class="name">{{ rtcStore.imSate.callName || "未知用户" }}</div>
  11. <div class="status">{{ statusText }}</div>
  12. </div>
  13. <!-- 视频通话 -->
  14. <div v-if="rtcStore.streamType === 'video'" class="video-container">
  15. <!-- 大屏视频 -->
  16. <video
  17. ref="mainVideo"
  18. autoplay
  19. playsinline
  20. class="remote-video"
  21. v-show="(inCall && rtcStore.remoteStream) || (rtcStore.isCaller && !inCall && rtcStore.localStream)"
  22. ></video>
  23. <!-- 占位层:显示对方头像和昵称 -->
  24. <div
  25. class="video-placeholder"
  26. v-if="(!inCall && (!rtcStore.isCaller || (rtcStore.isCaller && !rtcStore.remoteStream)))"
  27. >
  28. <img
  29. class="avatar"
  30. :src="formatAvatarUrl(rtcStore.imSate.callAvatar || defaultAvatar)"
  31. alt="头像"
  32. />
  33. <div class="name">{{ rtcStore.imSate.callName || "未知用户" }}</div>
  34. <div class="status">{{ statusText }}</div>
  35. </div>
  36. <!-- 小窗本地流:接通后且远程流存在才显示 -->
  37. <video
  38. ref="pipVideo"
  39. autoplay
  40. playsinline
  41. muted
  42. class="local-video-draggable"
  43. :style="{ top: localVideoPos.top + 'px', left: localVideoPos.left + 'px' }"
  44. v-show="rtcStore.connectionState === 'connected' && rtcStore.remoteStream && rtcStore.localStream"
  45. @mousedown="startDrag"
  46. @touchstart.prevent="startDrag"
  47. ></video>
  48. <!-- 通话时长:仅在视频接通后显示 -->
  49. <div
  50. class="call-timer"
  51. v-if="rtcStore.streamType === 'video' && rtcStore.connectionState === 'connected' && rtcStore.remoteStream"
  52. >
  53. {{ callDuration }}
  54. </div>
  55. </div>
  56. <div class="btn-group">
  57. <template v-if="rtcStore.isCaller">
  58. <button class="btn hangup" @click="hangupCall">取消</button>
  59. </template>
  60. <template v-else-if="!inCall">
  61. <button class="btn reject" @click="rejectCall">拒绝</button>
  62. <button class="btn accept" @click="acceptCall">接听</button>
  63. </template>
  64. <template v-else>
  65. <button class="btn hangup" @click="hangupCall">挂断</button>
  66. </template>
  67. </div>
  68. </div>
  69. </template>
  70. <script setup>
  71. import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
  72. import { useWebSocketStore } from "@/stores/modules/webSocketStore";
  73. import { useWebRTCStore } from "@/stores/modules/webrtcStore";
  74. import { useWalletStore } from "@/stores/modules/walletStore.js";
  75. import * as Constant from "@/common/constant/Constant";
  76. import { soundVoice } from "@/utils/notifications.js";
  77. import { MESSAGE_TYPE_USER } from "@/common/constant/msgType";
  78. const IM_PATH = import.meta.env.VITE_IM_PATH_FIlE;
  79. const wsStore = useWebSocketStore();
  80. const rtcStore = useWebRTCStore();
  81. const walletStore = useWalletStore();
  82. const callDuration = ref("00:00"); // 通话时长
  83. let timer = null;
  84. let startTime = null;
  85. const inCall = ref(false);
  86. const defaultAvatar = "https://example.com/default-avatar.png";
  87. const formatAvatarUrl = (url) => {
  88. if (/^https?:\/\//.test(url)) {
  89. return url
  90. }
  91. return IM_PATH + url
  92. }
  93. // 视频流引用
  94. const mainVideo = ref(null);
  95. const pipVideo = ref(null);
  96. // 控制大屏和小窗显示的流
  97. const mainStream = ref(null);
  98. const pipStream = ref(null);
  99. // 是否大屏显示本地流
  100. const showLocalMain = ref(true);
  101. // 小窗位置
  102. const localVideoPos = ref({ top: 20, left: 20 });
  103. let dragOffset = { x: 0, y: 0 };
  104. let dragging = false;
  105. // 点击起点
  106. let startClickPos = { x: 0, y: 0 };
  107. // 计时器逻辑
  108. function startTimer() {
  109. startTime = Date.now();
  110. timer = setInterval(() => {
  111. const diff = Math.floor((Date.now() - startTime) / 1000);
  112. const h = String(Math.floor(diff / 3600)).padStart(2, "0");
  113. const m = String(Math.floor((diff % 3600) / 60)).padStart(2, "0");
  114. const s = String(diff % 60).padStart(2, "0");
  115. callDuration.value = h > 0 ? `${h}:${m}:${s}` : `${m}:${s}`;
  116. }, 1000);
  117. }
  118. function stopTimer() {
  119. if (timer) {
  120. clearInterval(timer);
  121. timer = null;
  122. }
  123. callDuration.value = "00:00";
  124. }
  125. // 状态文字
  126. const statusText = computed(() => {
  127. if (rtcStore.connectionState === "connected" && rtcStore.remoteStream) {
  128. if (!timer) startTimer();
  129. return callDuration.value;
  130. }
  131. return inCall.value ? "链接中..." : "正在语音通话请求...";
  132. });
  133. // 接收消息处理
  134. function onMessage(message) {
  135. if (message.type !== Constant.MESSAGE_TRANS_TYPE) return;
  136. switch (message.contentType) {
  137. case Constant.DIAL_AUDIO_ONLINE:
  138. rtcStore.streamType = "audio";
  139. rtcStore.imSate.videoCallModal = true;
  140. rtcStore.imSate.callName = message.fromUsername || "未知用户";
  141. rtcStore.imSate.callAvatar = message.avatar || "";
  142. inCall.value = false;
  143. break;
  144. case Constant.DIAL_VIDEO_ONLINE:
  145. rtcStore.streamType = "video";
  146. break;
  147. case Constant.ACCEPT_VIDEO_ONLINE:
  148. rtcStore.streamType = "video";
  149. inCall.value = true;
  150. break;
  151. case Constant.ACCEPT_AUDIO_ONLINE:
  152. rtcStore.streamType = "audio";
  153. inCall.value = true;
  154. break;
  155. case Constant.REJECT_AUDIO_ONLINE:
  156. case Constant.CANCELL_AUDIO_ONLINE:
  157. case Constant.DIAL_MEDIA_END:
  158. rtcStore.imSate.videoCallModal = false;
  159. inCall.value = false;
  160. stopTimer();
  161. break;
  162. }
  163. }
  164. // 接听
  165. async function acceptCall() {
  166. soundVoice.stop();
  167. try {
  168. inCall.value = true;
  169. wsStore.sendMessage({
  170. messageType: MESSAGE_TYPE_USER,
  171. contentType:
  172. rtcStore.streamType !== "video"
  173. ? Constant.ACCEPT_AUDIO_ONLINE
  174. : Constant.ACCEPT_VIDEO_ONLINE,
  175. });
  176. } catch (err) {
  177. console.error("接听失败", err);
  178. inCall.value = false;
  179. }
  180. }
  181. // 拒接
  182. function rejectCall() {
  183. soundVoice.stop();
  184. stopTimer();
  185. rtcStore.cleanup();
  186. const sender = wsStore.toUserInfo.sender
  187. ? wsStore.toUserInfo.sender.uuid
  188. : walletStore.account;
  189. wsStore.sendMessage({
  190. messageType: MESSAGE_TYPE_USER,
  191. contentType: Constant.REJECT_AUDIO_ONLINE,
  192. type: Constant.MESSAGE_TRANS_TYPE,
  193. content: JSON.stringify({ content: "", sender }),
  194. });
  195. rtcStore.imSate.videoCallModal = false;
  196. inCall.value = false;
  197. }
  198. // 挂断
  199. function hangupCall() {
  200. soundVoice.stop();
  201. stopTimer();
  202. rtcStore.cleanup();
  203. const sender = wsStore.toUserInfo.sender
  204. ? wsStore.toUserInfo.sender.uuid
  205. : walletStore.account;
  206. wsStore.sendMessage({
  207. messageType: MESSAGE_TYPE_USER,
  208. contentType: Constant.CANCELL_AUDIO_ONLINE,
  209. type: Constant.MESSAGE_TRANS_TYPE,
  210. content: JSON.stringify({ content: "", sender }),
  211. });
  212. rtcStore.imSate.videoCallModal = false;
  213. inCall.value = false;
  214. }
  215. // 切换大屏/小窗
  216. function swapStreams() {
  217. showLocalMain.value = !showLocalMain.value;
  218. assignStreamsToVideo();
  219. }
  220. // 手动绑定视频流到 video 元素
  221. function assignStreamsToVideo() {
  222. if (rtcStore.connectionState === 'connected') {
  223. if (showLocalMain.value) {
  224. // 大屏显示自己,小窗显示对方
  225. mainStream.value = rtcStore.localStream;
  226. pipStream.value = rtcStore.remoteStream;
  227. } else {
  228. // 大屏显示对方,小窗显示自己
  229. mainStream.value = rtcStore.remoteStream;
  230. pipStream.value = rtcStore.localStream;
  231. }
  232. } else if (rtcStore.isCaller && !inCall.value && rtcStore.localStream) {
  233. // 拨打方未接通前:大屏显示自己
  234. mainStream.value = rtcStore.localStream;
  235. pipStream.value = null;
  236. } else {
  237. mainStream.value = null;
  238. pipStream.value = null;
  239. }
  240. if (mainVideo.value) mainVideo.value.srcObject = mainStream.value;
  241. if (pipVideo.value) pipVideo.value.srcObject = pipStream.value;
  242. }
  243. // 监听远程流
  244. watch(
  245. () => rtcStore.connectionState,
  246. (val) => {
  247. if(val === 'connected'){
  248. maybeStartTimer();
  249. assignStreamsToVideo();
  250. }
  251. },
  252. { immediate: true }
  253. );
  254. // 监听远程流
  255. watch(
  256. () => rtcStore.remoteStream,
  257. (val) => {
  258. maybeStartTimer();
  259. assignStreamsToVideo();
  260. },
  261. { immediate: true }
  262. );
  263. // 监听本地流
  264. watch(
  265. () => rtcStore.localStream,
  266. (val) => {
  267. maybeStartTimer();
  268. assignStreamsToVideo();
  269. },
  270. { immediate: true }
  271. );
  272. function maybeStartTimer() {
  273. if (rtcStore.connectionState === "connected" && rtcStore.remoteStream && !timer) {
  274. startTimer();
  275. }
  276. }
  277. // 拖拽逻辑 + 点击切换
  278. function startDrag(e) {
  279. dragging = true;
  280. const clientX = e.touches ? e.touches[0].clientX : e.clientX;
  281. const clientY = e.touches ? e.touches[0].clientY : e.clientY;
  282. dragOffset.x = clientX - localVideoPos.value.left;
  283. dragOffset.y = clientY - localVideoPos.value.top;
  284. startClickPos.x = clientX;
  285. startClickPos.y = clientY;
  286. }
  287. function onDrag(e) {
  288. if (!dragging) return;
  289. const clientX = e.touches ? e.touches[0].clientX : e.clientX;
  290. const clientY = e.touches ? e.touches[0].clientY : e.clientY;
  291. localVideoPos.value.left = clientX - dragOffset.x;
  292. localVideoPos.value.top = clientY - dragOffset.y;
  293. }
  294. function stopDrag(e) {
  295. if (!dragging) return;
  296. dragging = false;
  297. const clientX = e.changedTouches ? e.changedTouches[0].clientX : e.clientX;
  298. const clientY = e.changedTouches ? e.changedTouches[0].clientY : e.clientY;
  299. const dx = clientX - startClickPos.x;
  300. const dy = clientY - startClickPos.y;
  301. const distance = Math.sqrt(dx * dx + dy * dy);
  302. // 如果拖拽距离小于 5px,视为点击
  303. if (distance < 5) {
  304. swapStreams();
  305. }
  306. }
  307. onMounted(() => {
  308. wsStore.onMessageCallbacks.push(onMessage);
  309. document.addEventListener("mousemove", onDrag);
  310. document.addEventListener("mouseup", stopDrag);
  311. document.addEventListener("touchmove", onDrag);
  312. document.addEventListener("touchend", stopDrag);
  313. });
  314. onBeforeUnmount(() => {
  315. wsStore.onMessageCallbacks = wsStore.onMessageCallbacks.filter(
  316. (cb) => cb !== onMessage
  317. );
  318. document.removeEventListener("mousemove", onDrag);
  319. document.removeEventListener("mouseup", stopDrag);
  320. document.removeEventListener("touchmove", onDrag);
  321. document.removeEventListener("touchend", stopDrag);
  322. });
  323. </script>
  324. <style scoped lang="less">
  325. /* 样式保持原有逻辑 */
  326. .weixin-call-modal {
  327. position: fixed;
  328. inset: 0;
  329. background-color: black;
  330. display: flex;
  331. flex-direction: column;
  332. align-items: center;
  333. justify-content: center;
  334. z-index: 9999;
  335. color: white;
  336. }
  337. .caller-info {
  338. display: flex;
  339. flex-direction: column;
  340. align-items: center;
  341. margin-bottom: 80px;
  342. }
  343. .avatar {
  344. width: 80px;
  345. height: 80px;
  346. border-radius: 50%;
  347. margin-bottom: 16px;
  348. }
  349. .name {
  350. font-size: 20px;
  351. font-weight: bold;
  352. margin-bottom: 8px;
  353. }
  354. .status {
  355. font-size: 14px;
  356. color: #ccc;
  357. }
  358. .video-container {
  359. position: relative;
  360. width: 100%;
  361. height: 100%;
  362. }
  363. .remote-video {
  364. position: absolute;
  365. width: 100%;
  366. height: 100%;
  367. object-fit: cover;
  368. transform: scaleX(-1);
  369. }
  370. .local-video-draggable {
  371. position: absolute;
  372. width: 120px;
  373. height: 160px;
  374. border-radius: 8px;
  375. background: black;
  376. cursor: grab;
  377. object-fit: cover;
  378. z-index: 10;
  379. transform: scaleX(-1);
  380. }
  381. .btn-group {
  382. position: absolute;
  383. bottom: 40px;
  384. display: flex;
  385. gap: 60px;
  386. z-index: 9999; /* 提升层级,确保显示在视频/占位层之上 */
  387. }
  388. .btn {
  389. width: 64px;
  390. height: 64px;
  391. border-radius: 50%;
  392. border: none;
  393. font-size: 14px;
  394. color: white;
  395. display: flex;
  396. align-items: center;
  397. justify-content: center;
  398. cursor: pointer;
  399. }
  400. .btn.accept {
  401. background-color: #4cd964;
  402. }
  403. .btn.reject,
  404. .btn.hangup {
  405. background-color: #ff3b30;
  406. }
  407. .video-placeholder {
  408. position: absolute;
  409. inset: 0;
  410. display: flex;
  411. flex-direction: column;
  412. align-items: center;
  413. justify-content: center;
  414. background: rgba(0,0,0,0.4); // 半透明遮罩,显示在本地视频上
  415. color: white;
  416. z-index: 5;
  417. .avatar {
  418. width: 80px;
  419. height: 80px;
  420. border-radius: 50%;
  421. margin-bottom: 16px;
  422. }
  423. .name {
  424. font-size: 20px;
  425. font-weight: bold;
  426. margin-bottom: 8px;
  427. }
  428. .status {
  429. font-size: 14px;
  430. color: #ccc;
  431. }
  432. }
  433. .call-timer {
  434. position: absolute;
  435. top: 50%;
  436. left: 50%;
  437. transform: translate(-50%,-50%);
  438. padding: 6px 10px;
  439. background: rgba(0, 0, 0, 0.4);
  440. border-radius: 12px;
  441. font-size: 14px;
  442. line-height: 1;
  443. color: #fff;
  444. z-index: 20;
  445. }
  446. </style>