gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/components/rtc/rtc-call-running.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,291 @@
<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>