gaoluyang
2 天以前 b64a0deae5b5d33f9e20671a68936b27f0b9b00b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
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>