✨ feat(im): 优化群邀请的 running 的交互
parent
68922ebf02
commit
03d0ce800d
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue