| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, onUnmounted, ref, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { formatCallDuration } from '#/views/im/utils/time' |
| | | |
| | | import { useMediaStreamElement } from '../../composables/useMediaStreamElement' |
| | | import { UserAvatar } from '../user' |
| | | import RtcCallParticipantTile, { type CallParticipantVM } from './rtc-call-participant-tile.vue' |
| | | |
| | | const props = defineProps<{ |
| | | cameraEnabled: boolean |
| | | hangingUp?: boolean |
| | | /** æ¯å¦ç¾¤éè¯ï¼å³å®ç½æ ¼ / åç¹å¸å± + æµ®çªå¤§å° */ |
| | | isGroup: boolean |
| | | isVideo: boolean |
| | | localStream?: MediaStream | null |
| | | micEnabled: boolean |
| | | /** ç½æ ¼è§å¾ç¨ï¼ææåä¸è
ï¼å«èªå·±ï¼ */ |
| | | participants: CallParticipantVM[] |
| | | peerAvatar?: string |
| | | /** 1v1 è§å¾ç¨ */ |
| | | peerNickname?: string |
| | | /** æ¯å¦å¤äºç½ç»éè¿ä¸ï¼æ¾ç¤ºé¡¶é¨é»è²æç¤ºæ¡ */ |
| | | reconnecting?: boolean |
| | | remoteAudioStream?: MediaStream | null |
| | | remoteVideoStream?: MediaStream | null |
| | | /** æ¯å¦æ£å¨å±å¹å
±äº«ï¼æé®é«äº® + ææ¡åæ¢ */ |
| | | screenShareEnabled?: boolean |
| | | /** æ¬å£°å¨å¼å
³ï¼true æ¶æ£å¸¸ææ¾è¿ç«¯é³é¢ï¼false æ¶ææè¿ç«¯ audio å
ç´ éé³ */ |
| | | speakerEnabled: boolean |
| | | startedAt: number |
| | | }>() |
| | | |
| | | defineEmits<{ |
| | | addMember: [] |
| | | hangup: [] |
| | | toggleCamera: [] |
| | | toggleMic: [] |
| | | toggleScreenShare: [] |
| | | toggleSpeaker: [] |
| | | }>() |
| | | |
| | | /** ç½æ ¼åæ°ï¼æäººæ°èªéåºï¼è¿å UnoCSS class åé¢é让 JIT æ«æå¨éæè¯å« */ |
| | | const gridColsClass = computed(() => { |
| | | const n = props.participants.length |
| | | if (n <= 1) return 'grid-cols-1' |
| | | if (n <= 4) return 'grid-cols-2' |
| | | return 'grid-cols-3' |
| | | }) |
| | | |
| | | const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream) |
| | | const setRemoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream) |
| | | const setRemoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream) |
| | | |
| | | /** 1v1 è§é¢ï¼æ¯å¦æè¿ç«¯è§é¢æµ */ |
| | | const hasRemoteVideo = computed(() => !props.isGroup && !!props.remoteVideoStream) |
| | | |
| | | const now = ref(Date.now()) // éè¯æ¶é¿ï¼ä»
1v1 è¯é³è§å¾éè¦å±ç¤ºï¼å
¶å®è§å¾ä¸å¯ tick |
| | | let tick = 0 |
| | | watch( |
| | | () => props.isGroup || props.isVideo, |
| | | (suppressTick) => { |
| | | if (suppressTick) { |
| | | if (tick) { |
| | | clearInterval(tick) |
| | | tick = 0 |
| | | } |
| | | return |
| | | } |
| | | now.value = Date.now() |
| | | tick = window.setInterval(() => { |
| | | now.value = Date.now() |
| | | }, 1000) |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | onUnmounted(() => { |
| | | if (tick) { |
| | | clearInterval(tick) |
| | | } |
| | | }) |
| | | |
| | | /** éè¯æ¶é¿ MM:SS / HH:MM:SS */ |
| | | const formattedDuration = computed(() => |
| | | formatCallDuration(Math.floor((now.value - props.startedAt) / 1000)) |
| | | ) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- éè¯è¿è¡ä¸çæ¬æµ®çªï¼1v1 ç§è 320Ã540ï¼ç¾¤éè¯åå¤§çª 720Ã560 --> |
| | | <div |
| | | class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl overflow-hidden shadow-[0_12px_36px_rgba(0,0,0,0.35)] z-[1000] flex flex-col text-white bg-[#1a1a1c]" |
| | | :class="isGroup ? 'w-[720px] h-[560px]' : 'w-[320px] h-[540px]'" |
| | | > |
| | | <!-- éè¿ä¸æ¨ªå¹
ï¼ç½ç»æå¨æ¶æ¾ç¤ºï¼ç´å° Reconnected äºä»¶æ¸
é¤ --> |
| | | <div |
| | | v-if="reconnecting" |
| | | class="inline-flex absolute top-3 left-1/2 z-10 gap-2 items-center px-3.5 py-1.5 text-13px text-[#ffd45e] rounded-full -translate-x-1/2 bg-[rgba(255,196,0,0.18)]" |
| | | > |
| | | <span class="reconnect-dot w-2 h-2 rounded-full bg-[#ffd45e]"></span> |
| | | ç½ç»ä¸ä½³ï¼æ£å¨éè¿â¦â¦ |
| | | </div> |
| | | <div class="flex relative flex-1 justify-center items-center"> |
| | | <!-- 群éè¯ï¼ç½æ ¼å¸å±ï¼åæ°é人æ°èªéåº --> |
| | | <div |
| | | v-if="isGroup" |
| | | class="grid gap-1 p-1 w-full h-full content-stretch" |
| | | :class="gridColsClass" |
| | | > |
| | | <RtcCallParticipantTile |
| | | v-for="participant in participants" |
| | | :key="participant.userId" |
| | | :participant="participant" |
| | | :speaker-enabled="speakerEnabled" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- 1v1 è§é¢ï¼è¿ç«¯éºåº + æ¬å°ç¼©ç¥ --> |
| | | <template v-else-if="isVideo"> |
| | | <div v-show="hasRemoteVideo" class="absolute inset-0"> |
| | | <video |
| | | :ref="setRemoteVideoRef" |
| | | class="object-cover w-full h-full" |
| | | autoplay |
| | | playsinline |
| | | ></video> |
| | | </div> |
| | | <div v-if="!hasRemoteVideo" class="flex z-[1] flex-col gap-4 items-center"> |
| | | <UserAvatar |
| | | :url="peerAvatar" |
| | | :name="peerNickname" |
| | | :size="96" |
| | | radius="8px" |
| | | :clickable="false" |
| | | /> |
| | | <div class="text-[17px] font-medium">{{ peerNickname }}</div> |
| | | <div class="text-13px text-white/60">çå¾
对æ¹å¼å¯æå头â¦â¦</div> |
| | | </div> |
| | | <div |
| | | v-if="localStream" |
| | | class="absolute top-4 right-4 z-[2] overflow-hidden w-30 rounded-lg aspect-[9/16] bg-[#333]" |
| | | > |
| | | <video |
| | | :ref="setLocalVideoRef" |
| | | class="object-cover w-full h-full scale-x-[-1]" |
| | | autoplay |
| | | muted |
| | | playsinline |
| | | ></video> |
| | | </div> |
| | | </template> |
| | | |
| | | <!-- 1v1 è¯é³ï¼å¤´å + æ¶é¿ --> |
| | | <template v-else> |
| | | <div class="flex z-[1] flex-col gap-4 items-center"> |
| | | <UserAvatar |
| | | :url="peerAvatar" |
| | | :name="peerNickname" |
| | | :size="96" |
| | | radius="8px" |
| | | :clickable="false" |
| | | /> |
| | | <div class="text-[17px] font-medium">{{ peerNickname }}</div> |
| | | <div class="text-13px text-white/60">{{ formattedDuration }}</div> |
| | | </div> |
| | | </template> |
| | | <audio |
| | | v-if="!isGroup && remoteAudioStream" |
| | | :ref="setRemoteAudioRef" |
| | | autoplay |
| | | :muted="!speakerEnabled" |
| | | ></audio> |
| | | </div> |
| | | |
| | | <!-- åºé¨æä½åºï¼éº¦å
é£ / æ¬å£°å¨ / æå头 / (群èï¼å
±äº«å±å¹ / æ·»å æå) / ææ --> |
| | | <div |
| | | class="flex flex-shrink-0 gap-3 justify-around items-center pt-4 px-4 pb-5 bg-black/20" |
| | | > |
| | | <div |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" |
| | | @click="$emit('toggleMic')" |
| | | > |
| | | <span |
| | | class="flex justify-center items-center w-[52px] h-[52px] rounded-full" |
| | | :class="micEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'" |
| | | > |
| | | <Icon |
| | | :icon="micEnabled ? 'ant-design:audio-outlined' : 'ant-design:audio-muted-outlined'" |
| | | :size="22" |
| | | /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap"> |
| | | {{ micEnabled ? '麦å
é£å·²å¼' : '麦å
é£å·²å
³' }} |
| | | </span> |
| | | </div> |
| | | <div |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" |
| | | @click="$emit('toggleSpeaker')" |
| | | > |
| | | <span |
| | | class="flex justify-center items-center w-[52px] h-[52px] rounded-full" |
| | | :class="speakerEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'" |
| | | > |
| | | <Icon |
| | | :icon="speakerEnabled ? 'ant-design:sound-outlined' : 'tabler:volume-off'" |
| | | :size="22" |
| | | /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap"> |
| | | {{ speakerEnabled ? 'æ¬å£°å¨å·²å¼' : 'æ¬å£°å¨å·²å
³' }} |
| | | </span> |
| | | </div> |
| | | <!-- æå头æé®ï¼ç§èè§é¢åºå®æ¾ç¤ºï¼ç¾¤èä¸è®ºèµ·å§ mediaType 齿¾ç¤ºï¼è®©ç¨æ·æéå¼ --> |
| | | <div |
| | | v-if="isVideo || isGroup" |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" |
| | | @click="$emit('toggleCamera')" |
| | | > |
| | | <span |
| | | class="flex justify-center items-center w-[52px] h-[52px] rounded-full" |
| | | :class="cameraEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'" |
| | | > |
| | | <Icon |
| | | :icon="cameraEnabled ? 'ant-design:video-camera-outlined' : 'tabler:video-off'" |
| | | :size="22" |
| | | /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap"> |
| | | {{ cameraEnabled ? 'æå头已å¼' : 'æå头已å
³' }} |
| | | </span> |
| | | </div> |
| | | <!-- 群éè¯ææï¼å
±äº«å±å¹ / æ·»å æåï¼å
±äº«ä¸æé®é«äº® + ææ¡æ¢æã忢å
±äº«ã --> |
| | | <template v-if="isGroup"> |
| | | <div |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" |
| | | @click="$emit('toggleScreenShare')" |
| | | > |
| | | <span |
| | | class="flex justify-center items-center w-[52px] h-[52px] rounded-full" |
| | | :class="screenShareEnabled ? 'bg-[#07c160] text-white' : 'bg-white/15 text-white'" |
| | | > |
| | | <Icon |
| | | :icon="screenShareEnabled ? 'ant-design:laptop-outlined' : 'tabler:device-laptop-off'" |
| | | :size="22" |
| | | /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap"> |
| | | {{ screenShareEnabled ? '忢å
±äº«' : 'å
±äº«å±å¹' }} |
| | | </span> |
| | | </div> |
| | | <div |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" |
| | | @click="$emit('addMember')" |
| | | > |
| | | <span class="flex justify-center items-center w-[52px] h-[52px] text-white rounded-full bg-white/15"> |
| | | <Icon icon="ant-design:plus-outlined" :size="22" /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap">æ·»å æå</span> |
| | | </div> |
| | | </template> |
| | | <div |
| | | class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" |
| | | :class="{ 'opacity-60 pointer-events-none': hangingUp }" |
| | | @click="$emit('hangup')" |
| | | > |
| | | <span class="flex justify-center items-center w-[52px] h-[52px] text-white rounded-full bg-[#f04a4a]"> |
| | | <Icon icon="ant-design:phone-outlined" :size="22" class="rotate-[135deg]" /> |
| | | </span> |
| | | <span class="text-xs text-white/70 whitespace-nowrap">ææ</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* éè¿å°ç¹æ·¡å
¥æ·¡åºï¼@keyframes å¿
é¡» CSS å®ä¹ */ |
| | | .reconnect-dot { |
| | | animation: reconnect-pulse 1s ease-in-out infinite; |
| | | } |
| | | @keyframes reconnect-pulse { |
| | | 0%, |
| | | 100% { |
| | | opacity: 0.3; |
| | | } |
| | | 50% { |
| | | opacity: 1; |
| | | } |
| | | } |
| | | </style> |