feat(im): 优化群邀请的 running 的交互

im
YunaiV 2026-05-17 16:46:10 +08:00
parent 68922ebf02
commit 03d0ce800d
3 changed files with 800 additions and 0 deletions

View File

@ -0,0 +1,411 @@
<template>
<!-- 通话阶段对应弹窗INVITING / INCOMING / RUNNING 三选一互斥 -->
<template v-if="rtcStore.isActive">
<!-- 主叫端等待对方接听 -->
<RtcCallInviting
v-if="rtcStore.stage === ImRtcCallStage.INVITING && rtcStore.call"
:peer-nickname="rtcStore.peerNickname"
:peer-avatar="rtcStore.peerAvatar"
:is-group="isGroup"
:is-video="isVideo"
:mic-enabled="lk.micEnabled.value"
:camera-enabled="lk.cameraEnabled.value"
:speaker-enabled="lk.speakerEnabled.value"
:local-stream="localStream"
@cancel="handleCancel"
@toggle-mic="toggleMic"
@toggle-camera="toggleCamera"
@toggle-speaker="toggleSpeaker"
/>
<!-- 被叫端来电响铃 -->
<RtcCallIncoming
v-else-if="rtcStore.stage === ImRtcCallStage.INCOMING"
:payload="rtcStore.incomingPayload"
:is-group="isGroup"
@accept="handleAccept"
@reject="handleReject"
/>
<!-- 通话进行中1v1 视频 / 语音 + 群通话宫格 -->
<RtcCallRunning
v-else-if="rtcStore.stage === ImRtcCallStage.RUNNING && rtcStore.call"
:is-group="isGroup"
:is-video="isVideo"
:mic-enabled="lk.micEnabled.value"
:camera-enabled="lk.cameraEnabled.value"
:speaker-enabled="lk.speakerEnabled.value"
:screen-share-enabled="lk.screenShareEnabled.value"
:reconnecting="lk.reconnecting.value"
:started-at="rtcStore.startedAt"
:participants="participants"
:peer-nickname="rtcStore.peerNickname"
:peer-avatar="rtcStore.peerAvatar"
:local-stream="localStream"
:remote-video-stream="remoteVideoStream"
:remote-audio-stream="remoteAudioStream"
@hangup="handleHangup"
@toggle-mic="toggleMic"
@toggle-camera="toggleCamera"
@toggle-speaker="toggleSpeaker"
@toggle-screen-share="handleScreenShare"
@add-member="openAddMember"
/>
</template>
<!-- 通话中添加成员选人弹窗挂在 isActive 避免 stage 切换瞬间弹窗被卸载 -->
<RtcCallMemberPickerDialog ref="memberPickerRef" @success="handleAddMemberSuccess" />
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { useRtcStore } from '../../store/rtcStore'
import { useLiveKitRoom } from '../../composables/useLiveKitRoom'
import {
cancelCall,
rejectCall,
acceptCall,
leaveCall,
inviteCall
} from '@/api/im/home/rtc'
import {
ImRtcCallMediaType,
ImRtcCallStage,
ImConversationType
} from '@/views/im/utils/constants'
import { getCurrentUserId } from '@/views/im/utils/storage'
import { getSenderAvatar, getSenderDisplayName } from '@/views/im/utils/user'
import { Track } from 'livekit-client'
import RtcCallInviting from './RtcCallInviting.vue'
import RtcCallIncoming from './RtcCallIncoming.vue'
import RtcCallRunning from './RtcCallRunning.vue'
import RtcCallMemberPickerDialog from './RtcCallMemberPickerDialog.vue'
import type { CallParticipantVM } from './RtcCallParticipantTile.vue'
defineOptions({ name: 'ImRtcCallContainer' })
const rtcStore = useRtcStore()
const message = useMessage()
const lk = useLiveKitRoom()
const memberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>()
// ==================== ====================
/** 当前是否视频通话 */
const isVideo = computed(() => {
const t =
rtcStore.call?.mediaType ||
rtcStore.incomingPayload?.mediaType ||
ImRtcCallMediaType.VOICE
return t === ImRtcCallMediaType.VIDEO
})
/** 当前是否群通话;决定浮动窗大小 */
const isGroup = computed(
() =>
(rtcStore.call?.conversationType ??
rtcStore.incomingPayload?.conversationType) === ImConversationType.GROUP
)
/** 初始摄像头是否打开;群通话默认全部关闭,进入后用户主动开 */
const initialCamera = computed(() => {
if (rtcStore.call?.conversationType === ImConversationType.GROUP) {
return false
}
return isVideo.value
})
/** 本端视频流;优先 ScreenShare屏共时也铺底无则 Camera显式订阅 screenShareEnabled / cameraEnabled 触发重算 */
const localStream = computed<MediaStream | null>(() => {
// / computed pickStream Map
void lk.screenShareEnabled.value
void lk.cameraEnabled.value
const lp = lk.localParticipant.value
if (!lp) {
return null
}
return lk.pickStream(lp, Track.Source.ScreenShare) || lk.pickStream(lp, Track.Source.Camera)
})
/** 远端视频流(仅 1v1 用);优先 ScreenShare无则取 Camera */
const remoteVideoStream = computed<MediaStream | null>(() => {
if (isGroup.value) {
return null
}
for (const rp of lk.remoteParticipants.value) {
const screen = lk.pickStream(rp, Track.Source.ScreenShare)
if (screen) {
return screen
}
const camera = lk.pickStream(rp, Track.Source.Camera)
if (camera) {
return camera
}
}
return null
})
/** 远端音频流(仅 1v1 用) */
const remoteAudioStream = computed<MediaStream | null>(() => {
if (isGroup.value) {
return null
}
for (const rp of lk.remoteParticipants.value) {
const stream = lk.pickStream(rp, Track.Source.Microphone)
if (stream) {
return stream
}
}
return null
})
/** 群通话网格用:自己 + 远端在房 + 待加入成员;昵称 / 头像走 user.ts helper 自动处理 self / 群成员 / 好友 / 兜底 */
const participants = computed<CallParticipantVM[]>(() => {
const call = rtcStore.call
if (!call) {
return []
}
const conversationType = call.conversationType
const targetId = call.groupId ?? 0
const myId = getCurrentUserId()
const result: CallParticipantVM[] = []
//
result.push({
userId: myId,
nickname: getSenderDisplayName(myId, conversationType, targetId),
avatar: getSenderAvatar(myId, conversationType, targetId) || undefined,
isLocal: true,
videoStream: localStream.value
})
// Camera
const joined = new Set<number>()
for (const rp of lk.remoteParticipants.value) {
const userId = Number(rp.identity)
if (Number.isNaN(userId)) {
continue
}
joined.add(userId)
result.push({
userId,
nickname: getSenderDisplayName(userId, conversationType, targetId),
avatar: getSenderAvatar(userId, conversationType, targetId) || undefined,
isLocal: false,
videoStream:
lk.pickStream(rp, Track.Source.ScreenShare) || lk.pickStream(rp, Track.Source.Camera),
audioStream: lk.pickStream(rp, Track.Source.Microphone)
})
}
// pending 退 /
if (conversationType === ImConversationType.GROUP) {
const inviteeIds = call.inviteeIds || []
for (const userId of inviteeIds) {
if (userId === myId || joined.has(userId) || rtcStore.isUserLeft(userId)) {
continue
}
result.push({
userId,
nickname: getSenderDisplayName(userId, ImConversationType.GROUP, targetId),
avatar: getSenderAvatar(userId, ImConversationType.GROUP, targetId) || undefined,
isLocal: false,
pending: true
})
}
}
return result
})
// ==================== LiveKit ====================
/** 连入 LiveKit 房间并注册离开回调INVITING 主叫预连和被叫 accept 后连入共用 */
async function connectLiveKit(livekitUrl: string, token: string) {
// lk.connect room.value stage
if (lk.room.value) {
return
}
// connect handler
lk.onDisconnected(() => handlePeerDisconnected())
lk.onParticipantConnected(maybeEnterRunning)
lk.onParticipantDisconnected((userId) => rtcStore.markUserLeft(userId))
await lk.connect(livekitUrl, token, { audio: true, video: initialCamera.value })
// connect handler RUNNING
if (lk.remoteParticipants.value.length > 0) {
maybeEnterRunning()
}
}
/** 主叫端:从 INVITING 切到 RUNNING其它阶段不处理 */
function maybeEnterRunning() {
if (rtcStore.stage === ImRtcCallStage.INVITING && rtcStore.call) {
rtcStore.enterRunning(rtcStore.call)
}
}
watch(
() => rtcStore.stage,
async (stage) => {
if (
stage === ImRtcCallStage.INVITING &&
rtcStore.call?.token &&
rtcStore.call?.livekitUrl
) {
try {
await connectLiveKit(rtcStore.call.livekitUrl, rtcStore.call.token)
} catch (e) {
console.error('[Call] connect 失败', { room: rtcStore.call?.room }, e)
message.error('通话连接失败')
await handleCancel()
}
}
if (stage === ImRtcCallStage.IDLE) {
await lk.disconnect()
}
}
)
/** 被叫端 accept 后会拿到 token这里监听 stage + token 变化触发连接 */
watch(
() => [rtcStore.stage, rtcStore.call?.token],
async ([stage, token], [prevStage]) => {
if (
stage === ImRtcCallStage.RUNNING &&
prevStage !== ImRtcCallStage.RUNNING &&
token &&
!lk.isConnected.value &&
rtcStore.call?.livekitUrl
) {
try {
await connectLiveKit(rtcStore.call.livekitUrl, token as string)
} catch (e) {
console.error('[Call] accept connect 失败', { room: rtcStore.call?.room }, e)
message.error('通话连接失败')
// accept JOINED leave 线
if (rtcStore.call?.room) {
leaveCall(rtcStore.call.room).catch(() => undefined)
}
rtcStore.reset()
}
}
}
)
// ==================== ====================
/** 主叫取消邀请 */
async function handleCancel() {
const room = rtcStore.call?.room
if (room) {
try {
await cancelCall(room)
} catch (e) {
console.warn('[Call] cancel 失败', { room }, e)
}
}
await lk.disconnect()
rtcStore.reset()
}
/** 被叫拒绝来电 */
async function handleReject() {
const room = rtcStore.incomingPayload?.room
if (room) {
try {
await rejectCall(room)
} catch (e) {
console.warn('[Call] reject 失败', { room }, e)
}
}
rtcStore.reset()
}
/** 被叫接听来电 */
async function handleAccept() {
const payload = rtcStore.incomingPayload
if (!payload) return
try {
const data = await acceptCall(payload.room)
rtcStore.enterRunning(data)
} catch (e: any) {
console.error('[Call] accept 失败', { room: payload.room }, e)
message.error(e?.msg || '接听失败')
rtcStore.reset()
}
}
/** 通话中挂断 */
async function handleHangup() {
const room = rtcStore.call?.room
if (room) {
try {
await leaveCall(room)
} catch (e) {
console.warn('[Call] leave 失败', { room }, e)
}
}
await lk.disconnect()
rtcStore.reset()
}
/** LiveKit Room 异常断开;多见于网络中断 */
function handlePeerDisconnected() {
if (!rtcStore.isActive) return
message.warning('通话已断开')
rtcStore.reset()
}
// ==================== ====================
async function toggleMic() {
await lk.setMicEnabled(!lk.micEnabled.value)
}
async function toggleCamera() {
await lk.setCameraEnabled(!lk.cameraEnabled.value)
}
function toggleSpeaker() {
lk.setSpeakerEnabled(!lk.speakerEnabled.value)
}
/** 切屏幕共享浏览器弹原生「选择共享内容」对话框用户取消时会抛错UI 不弹提示 */
async function handleScreenShare() {
const enabled = !lk.screenShareEnabled.value
try {
await lk.setScreenShareEnabled(enabled)
} catch (e: any) {
//
if (e?.name !== 'NotAllowedError' && e?.message !== 'permission denied') {
console.warn('[Call] screenShare 切换失败', { enabled }, e)
}
}
}
// ==================== ====================
/** 打开「添加成员」弹窗;占位群通话 + 接通中状态才允许 */
function openAddMember() {
const call = rtcStore.call
if (!call?.groupId) {
return
}
memberPickerRef.value?.open({
groupId: call.groupId,
mode: 'add',
excludeUserIds: participants.value.map((p) => p.userId)
})
}
/** picker 选完成员;走 invite 追加邀请接口,后端推 RTC_INVITE 给新成员 */
async function handleAddMemberSuccess(userIds: number[]) {
const call = rtcStore.call
if (!call?.room || userIds.length === 0) {
return
}
try {
await inviteCall({ room: call.room, inviteeIds: userIds })
message.success('已发送邀请')
} catch (e: any) {
console.error('[Call] invite 追加失败', { room: call.room, inviteeIds: userIds }, e)
message.error(e?.msg || '添加成员失败')
}
}
</script>

View File

@ -0,0 +1,110 @@
<template>
<!--
群通话宫格里的单个参与者格子
优先渲染远端视频 videoStream 时铺满无视频降级渲染头像 + 名字胶囊
pending=true 时叠加接入中三点动画占位用于被邀请人未接通前的占位渲染
远端音频通过隐藏 audio 播放本端 isLocal 不渲染避免回声
-->
<div
class="flex relative overflow-hidden justify-center items-center w-full h-full rounded-md bg-[#2a2a2c]"
>
<!-- 视频可用渲染 video否则渲染头像或默认占位 -->
<video
v-if="participant.videoStream"
ref="videoRef"
class="object-cover w-full h-full"
autoplay
playsinline
:muted="participant.isLocal"
></video>
<div v-else class="flex justify-center items-center w-full h-full">
<UserAvatar
:url="participant.avatar"
:name="participant.nickname"
:size="64"
radius="50%"
:clickable="false"
/>
</div>
<!-- 远端音频通过 audio 元素播放本端静音避免回声扬声器关闭时整体静音 -->
<audio
v-if="participant.audioStream && !participant.isLocal"
ref="audioRef"
autoplay
:muted="!speakerEnabled"
></audio>
<!-- 左下角名字胶囊 -->
<div
class="flex absolute bottom-3 left-3 gap-1.5 items-center py-[3px] pr-2.5 pl-[3px] text-13px text-white rounded-full bg-black/45 max-w-[calc(100%-60px)]"
>
<UserAvatar
:url="participant.avatar"
:name="participant.nickname"
:size="22"
radius="50%"
:clickable="false"
/>
<span class="truncate min-w-0">{{ participant.nickname }}</span>
</div>
<!-- 等待对方加入三点动画 +接入中文案位置在格子中心下方 60px -->
<div
v-if="participant.pending"
class="flex absolute left-1/2 flex-col gap-1.5 items-center -translate-x-1/2 -translate-y-1/2 top-[calc(50%+60px)]"
>
<div class="flex gap-1.5">
<span
v-for="i in 3"
:key="i"
class="tile-dot w-1.5 h-1.5 rounded-full bg-white/60"
:style="{ animationDelay: `${(i - 1) * 0.2}s` }"
></span>
</div>
<span class="text-xs text-white/70">接入中</span>
</div>
</div>
</template>
<script lang="ts" setup>
import UserAvatar from '../user/UserAvatar.vue'
import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
export interface CallParticipantVM {
userId: number
nickname: string
avatar?: string
isLocal: boolean
videoStream?: MediaStream | null
audioStream?: MediaStream | null
/** 等待加入UI 显示三点动画 */
pending?: boolean
}
const props = defineProps<{
participant: CallParticipantVM
/** 扬声器开关;为 false 时静音该格子的远端音频 */
speakerEnabled: boolean
}>()
const videoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream)
const audioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream)
</script>
<style scoped>
/* 三点淡入淡出动画;@keyframes 必须 CSS 定义,再由 UnoCSS 之外的类名引用 */
.tile-dot {
animation: tile-dot 1.4s infinite ease-in-out both;
}
@keyframes tile-dot {
0%,
80%,
100% {
opacity: 0.3;
}
40% {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,279 @@
<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="p in participants"
:key="p.userId"
:participant="p"
:speaker-enabled="speakerEnabled"
/>
</div>
<!-- 1v1 视频远端铺底 + 本地缩略 -->
<template v-else-if="isVideo">
<div v-show="hasRemoteVideo" class="absolute inset-0">
<video
ref="remoteVideoRef"
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="localVideoRef"
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>
<audio v-if="remoteAudioStream" ref="remoteAudioRef" autoplay :muted="!speakerEnabled"></audio>
</template>
</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('toggle-mic')"
>
<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('toggle-speaker')"
>
<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('toggle-camera')"
>
<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('toggle-screen-share')"
>
<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="ant-design:laptop-outlined" :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('add-member')"
>
<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]"
@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>
<script lang="ts" setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import UserAvatar from '../user/UserAvatar.vue'
import RtcCallParticipantTile, { type CallParticipantVM } from './RtcCallParticipantTile.vue'
import { formatCallDuration } from '@/views/im/utils/time'
import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
const props = defineProps<{
/** 是否群通话;决定网格 / 单点布局 + 浮窗大小 */
isGroup: boolean
isVideo: boolean
micEnabled: boolean
cameraEnabled: boolean
/** 扬声器开关true 时正常播放远端音频false 时所有远端 audio 元素静音 */
speakerEnabled: boolean
/** 是否正在屏幕共享;按钮高亮 + 文案切换 */
screenShareEnabled?: boolean
/** 是否处于网络重连中;显示顶部黄色提示条 */
reconnecting?: boolean
startedAt: number
/** 网格视图用:所有参与者(含自己) */
participants: CallParticipantVM[]
/** 1v1 视图用 */
peerNickname?: string
peerAvatar?: string
localStream?: MediaStream | null
remoteVideoStream?: MediaStream | null
remoteAudioStream?: MediaStream | null
}>()
defineEmits<{
hangup: []
'toggle-mic': []
'toggle-camera': []
'toggle-speaker': []
'toggle-screen-share': []
'add-member': []
}>()
/** 网格列数;按人数自适应;返回 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 localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
const remoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream)
const remoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream)
/** 1v1 视频:是否有远端视频流 */
const hasRemoteVideo = computed(() => !props.isGroup && !!props.remoteVideoStream)
/** 通话时长;仅 1v1 语音视图需要展示,其它视图不启 tick */
const now = ref(Date.now())
let tick = 0
watch(
() => props.isGroup || props.isVideo,
(hidden) => {
if (hidden) {
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>
<style scoped>
/* 重连小点淡入淡出;@keyframes 必须 CSS 定义,再由非 UnoCSS 类名引用 */
.reconnect-dot {
animation: reconnect-pulse 1s ease-in-out infinite;
}
@keyframes reconnect-pulse {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 1;
}
}
</style>