123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500 |
- <template>
- <div v-if="rtcStore.imSate.videoCallModal" class="weixin-call-modal">
- <!-- 音频通话 -->
- <div class="caller-info" v-if="rtcStore.streamType == 'audio'">
- <img
- class="avatar"
- :src="formatAvatarUrl(rtcStore.imSate.callAvatar || defaultAvatar)"
- alt="头像"
- />
- <div class="name">{{ rtcStore.imSate.callName || "未知用户" }}</div>
- <div class="status">{{ statusText }}</div>
- </div>
- <!-- 视频通话 -->
- <div v-if="rtcStore.streamType === 'video'" class="video-container">
- <!-- 大屏视频 -->
- <video
- ref="mainVideo"
- autoplay
- playsinline
- class="remote-video"
- v-show="(inCall && rtcStore.remoteStream) || (rtcStore.isCaller && !inCall && rtcStore.localStream)"
- ></video>
- <!-- 占位层:显示对方头像和昵称 -->
- <div
- class="video-placeholder"
- v-if="(!inCall && (!rtcStore.isCaller || (rtcStore.isCaller && !rtcStore.remoteStream)))"
- >
- <img
- class="avatar"
- :src="formatAvatarUrl(rtcStore.imSate.callAvatar || defaultAvatar)"
- alt="头像"
- />
- <div class="name">{{ rtcStore.imSate.callName || "未知用户" }}</div>
- <div class="status">{{ statusText }}</div>
- </div>
- <!-- 小窗本地流:接通后且远程流存在才显示 -->
- <video
- ref="pipVideo"
- autoplay
- playsinline
- muted
- class="local-video-draggable"
- :style="{ top: localVideoPos.top + 'px', left: localVideoPos.left + 'px' }"
- v-show="rtcStore.connectionState === 'connected' && rtcStore.remoteStream && rtcStore.localStream"
- @mousedown="startDrag"
- @touchstart.prevent="startDrag"
- ></video>
- <!-- 通话时长:仅在视频接通后显示 -->
- <div
- class="call-timer"
- v-if="rtcStore.streamType === 'video' && rtcStore.connectionState === 'connected' && rtcStore.remoteStream"
- >
- {{ callDuration }}
- </div>
- </div>
- <div class="btn-group">
- <template v-if="rtcStore.isCaller">
- <button class="btn hangup" @click="hangupCall">取消</button>
- </template>
- <template v-else-if="!inCall">
- <button class="btn reject" @click="rejectCall">拒绝</button>
- <button class="btn accept" @click="acceptCall">接听</button>
- </template>
- <template v-else>
- <button class="btn hangup" @click="hangupCall">挂断</button>
- </template>
- </div>
- </div>
- </template>
- <script setup>
- import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
- import { useWebSocketStore } from "@/stores/modules/webSocketStore";
- import { useWebRTCStore } from "@/stores/modules/webrtcStore";
- import { useWalletStore } from "@/stores/modules/walletStore.js";
- import * as Constant from "@/common/constant/Constant";
- import { soundVoice } from "@/utils/notifications.js";
- import { MESSAGE_TYPE_USER } from "@/common/constant/msgType";
- const IM_PATH = import.meta.env.VITE_IM_PATH_FIlE;
- const wsStore = useWebSocketStore();
- const rtcStore = useWebRTCStore();
- const walletStore = useWalletStore();
- const callDuration = ref("00:00"); // 通话时长
- let timer = null;
- let startTime = null;
- const inCall = ref(false);
- const defaultAvatar = "https://example.com/default-avatar.png";
- const formatAvatarUrl = (url) => {
- if (/^https?:\/\//.test(url)) {
- return url
- }
- return IM_PATH + url
- }
- // 视频流引用
- const mainVideo = ref(null);
- const pipVideo = ref(null);
- // 控制大屏和小窗显示的流
- const mainStream = ref(null);
- const pipStream = ref(null);
- // 是否大屏显示本地流
- const showLocalMain = ref(true);
- // 小窗位置
- const localVideoPos = ref({ top: 20, left: 20 });
- let dragOffset = { x: 0, y: 0 };
- let dragging = false;
- // 点击起点
- let startClickPos = { x: 0, y: 0 };
- // 计时器逻辑
- function startTimer() {
- startTime = Date.now();
- timer = setInterval(() => {
- const diff = Math.floor((Date.now() - startTime) / 1000);
- const h = String(Math.floor(diff / 3600)).padStart(2, "0");
- const m = String(Math.floor((diff % 3600) / 60)).padStart(2, "0");
- const s = String(diff % 60).padStart(2, "0");
- callDuration.value = h > 0 ? `${h}:${m}:${s}` : `${m}:${s}`;
- }, 1000);
- }
- function stopTimer() {
- if (timer) {
- clearInterval(timer);
- timer = null;
- }
- callDuration.value = "00:00";
- }
- // 状态文字
- const statusText = computed(() => {
- if (rtcStore.connectionState === "connected" && rtcStore.remoteStream) {
- if (!timer) startTimer();
- return callDuration.value;
- }
- return inCall.value ? "链接中..." : "正在语音通话请求...";
- });
- // 接收消息处理
- function onMessage(message) {
- if (message.type !== Constant.MESSAGE_TRANS_TYPE) return;
- switch (message.contentType) {
- case Constant.DIAL_AUDIO_ONLINE:
- rtcStore.streamType = "audio";
- rtcStore.imSate.videoCallModal = true;
- rtcStore.imSate.callName = message.fromUsername || "未知用户";
- rtcStore.imSate.callAvatar = message.avatar || "";
- inCall.value = false;
- break;
- case Constant.DIAL_VIDEO_ONLINE:
- rtcStore.streamType = "video";
- break;
- case Constant.ACCEPT_VIDEO_ONLINE:
- rtcStore.streamType = "video";
- inCall.value = true;
- break;
- case Constant.ACCEPT_AUDIO_ONLINE:
- rtcStore.streamType = "audio";
- inCall.value = true;
- break;
- case Constant.REJECT_AUDIO_ONLINE:
- case Constant.CANCELL_AUDIO_ONLINE:
- case Constant.DIAL_MEDIA_END:
- rtcStore.imSate.videoCallModal = false;
- inCall.value = false;
- stopTimer();
- break;
- }
- }
- // 接听
- async function acceptCall() {
- soundVoice.stop();
- try {
- inCall.value = true;
- wsStore.sendMessage({
- messageType: MESSAGE_TYPE_USER,
- contentType:
- rtcStore.streamType !== "video"
- ? Constant.ACCEPT_AUDIO_ONLINE
- : Constant.ACCEPT_VIDEO_ONLINE,
- });
- } catch (err) {
- console.error("接听失败", err);
- inCall.value = false;
- }
- }
- // 拒接
- function rejectCall() {
- soundVoice.stop();
- stopTimer();
- rtcStore.cleanup();
- const sender = wsStore.toUserInfo.sender
- ? wsStore.toUserInfo.sender.uuid
- : walletStore.account;
- wsStore.sendMessage({
- messageType: MESSAGE_TYPE_USER,
- contentType: Constant.REJECT_AUDIO_ONLINE,
- type: Constant.MESSAGE_TRANS_TYPE,
- content: JSON.stringify({ content: "", sender }),
- });
- rtcStore.imSate.videoCallModal = false;
- inCall.value = false;
- }
- // 挂断
- function hangupCall() {
- soundVoice.stop();
- stopTimer();
- rtcStore.cleanup();
- const sender = wsStore.toUserInfo.sender
- ? wsStore.toUserInfo.sender.uuid
- : walletStore.account;
- wsStore.sendMessage({
- messageType: MESSAGE_TYPE_USER,
- contentType: Constant.CANCELL_AUDIO_ONLINE,
- type: Constant.MESSAGE_TRANS_TYPE,
- content: JSON.stringify({ content: "", sender }),
- });
- rtcStore.imSate.videoCallModal = false;
- inCall.value = false;
- }
- // 切换大屏/小窗
- function swapStreams() {
- showLocalMain.value = !showLocalMain.value;
- assignStreamsToVideo();
- }
- // 手动绑定视频流到 video 元素
- function assignStreamsToVideo() {
- if (rtcStore.connectionState === 'connected') {
- if (showLocalMain.value) {
- // 大屏显示自己,小窗显示对方
- mainStream.value = rtcStore.localStream;
- pipStream.value = rtcStore.remoteStream;
- } else {
- // 大屏显示对方,小窗显示自己
- mainStream.value = rtcStore.remoteStream;
- pipStream.value = rtcStore.localStream;
- }
- } else if (rtcStore.isCaller && !inCall.value && rtcStore.localStream) {
- // 拨打方未接通前:大屏显示自己
- mainStream.value = rtcStore.localStream;
- pipStream.value = null;
- } else {
- mainStream.value = null;
- pipStream.value = null;
- }
- if (mainVideo.value) mainVideo.value.srcObject = mainStream.value;
- if (pipVideo.value) pipVideo.value.srcObject = pipStream.value;
- }
- // 监听远程流
- watch(
- () => rtcStore.connectionState,
- (val) => {
- if(val === 'connected'){
- maybeStartTimer();
- assignStreamsToVideo();
- }
- },
- { immediate: true }
- );
- // 监听远程流
- watch(
- () => rtcStore.remoteStream,
- (val) => {
- maybeStartTimer();
- assignStreamsToVideo();
- },
- { immediate: true }
- );
- // 监听本地流
- watch(
- () => rtcStore.localStream,
- (val) => {
- maybeStartTimer();
- assignStreamsToVideo();
- },
- { immediate: true }
- );
- function maybeStartTimer() {
- if (rtcStore.connectionState === "connected" && rtcStore.remoteStream && !timer) {
- startTimer();
- }
- }
- // 拖拽逻辑 + 点击切换
- function startDrag(e) {
- dragging = true;
- const clientX = e.touches ? e.touches[0].clientX : e.clientX;
- const clientY = e.touches ? e.touches[0].clientY : e.clientY;
- dragOffset.x = clientX - localVideoPos.value.left;
- dragOffset.y = clientY - localVideoPos.value.top;
- startClickPos.x = clientX;
- startClickPos.y = clientY;
- }
- function onDrag(e) {
- if (!dragging) return;
- const clientX = e.touches ? e.touches[0].clientX : e.clientX;
- const clientY = e.touches ? e.touches[0].clientY : e.clientY;
- localVideoPos.value.left = clientX - dragOffset.x;
- localVideoPos.value.top = clientY - dragOffset.y;
- }
- function stopDrag(e) {
- if (!dragging) return;
- dragging = false;
- const clientX = e.changedTouches ? e.changedTouches[0].clientX : e.clientX;
- const clientY = e.changedTouches ? e.changedTouches[0].clientY : e.clientY;
- const dx = clientX - startClickPos.x;
- const dy = clientY - startClickPos.y;
- const distance = Math.sqrt(dx * dx + dy * dy);
- // 如果拖拽距离小于 5px,视为点击
- if (distance < 5) {
- swapStreams();
- }
- }
- onMounted(() => {
- wsStore.onMessageCallbacks.push(onMessage);
- document.addEventListener("mousemove", onDrag);
- document.addEventListener("mouseup", stopDrag);
- document.addEventListener("touchmove", onDrag);
- document.addEventListener("touchend", stopDrag);
- });
- onBeforeUnmount(() => {
- wsStore.onMessageCallbacks = wsStore.onMessageCallbacks.filter(
- (cb) => cb !== onMessage
- );
- document.removeEventListener("mousemove", onDrag);
- document.removeEventListener("mouseup", stopDrag);
- document.removeEventListener("touchmove", onDrag);
- document.removeEventListener("touchend", stopDrag);
- });
- </script>
- <style scoped lang="less">
- /* 样式保持原有逻辑 */
- .weixin-call-modal {
- position: fixed;
- inset: 0;
- background-color: black;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- z-index: 9999;
- color: white;
- }
- .caller-info {
- display: flex;
- flex-direction: column;
- align-items: center;
- margin-bottom: 80px;
- }
- .avatar {
- width: 80px;
- height: 80px;
- border-radius: 50%;
- margin-bottom: 16px;
- }
- .name {
- font-size: 20px;
- font-weight: bold;
- margin-bottom: 8px;
- }
- .status {
- font-size: 14px;
- color: #ccc;
- }
- .video-container {
- position: relative;
- width: 100%;
- height: 100%;
- }
- .remote-video {
- position: absolute;
- width: 100%;
- height: 100%;
- object-fit: cover;
- transform: scaleX(-1);
- }
- .local-video-draggable {
- position: absolute;
- width: 120px;
- height: 160px;
- border-radius: 8px;
- background: black;
- cursor: grab;
- object-fit: cover;
- z-index: 10;
- transform: scaleX(-1);
- }
- .btn-group {
- position: absolute;
- bottom: 40px;
- display: flex;
- gap: 60px;
- z-index: 9999; /* 提升层级,确保显示在视频/占位层之上 */
- }
- .btn {
- width: 64px;
- height: 64px;
- border-radius: 50%;
- border: none;
- font-size: 14px;
- color: white;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- }
- .btn.accept {
- background-color: #4cd964;
- }
- .btn.reject,
- .btn.hangup {
- background-color: #ff3b30;
- }
- .video-placeholder {
- position: absolute;
- inset: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- background: rgba(0,0,0,0.4); // 半透明遮罩,显示在本地视频上
- color: white;
- z-index: 5;
- .avatar {
- width: 80px;
- height: 80px;
- border-radius: 50%;
- margin-bottom: 16px;
- }
- .name {
- font-size: 20px;
- font-weight: bold;
- margin-bottom: 8px;
- }
- .status {
- font-size: 14px;
- color: #ccc;
- }
- }
- .call-timer {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%,-50%);
- padding: 6px 10px;
- background: rgba(0, 0, 0, 0.4);
- border-radius: 12px;
- font-size: 14px;
- line-height: 1;
- color: #fff;
- z-index: 20;
- }
- </style>
|