admin-vue3/src/views/im/home/components/rtc/RtcCallContainer.vue

461 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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 { useIntervalFn } from '@vueuse/core'
import { useMessage } from '@/hooks/web/useMessage'
import { useRtcStore } from '../../store/rtcStore'
import { useLiveKitRoom } from '../../composables/useLiveKitRoom'
import {
cancelCall,
rejectCall,
acceptCall,
leaveCall,
inviteCall,
noAnswerCallCheck
} from '@/api/im/rtc'
import {
ImRtcCallMediaType,
ImRtcCallStage,
ImConversationType
} from '@/views/im/utils/constants'
import { RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS } from '@/views/im/utils/config'
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 payload = rtcStore.incomingPayload
if (payload?.room) {
try {
await rejectCall(payload.room)
} catch (e) {
console.warn('[Call] reject 失败', { room: payload.room }, e)
}
// 本端先行从胶囊条移除自己,免等后端 RTC_CALL(REJECTED) 推回;私聊场景 store 内部 no-op
rtcStore.applyParticipantRejected({
room: payload.room,
conversationType: payload.conversationType,
groupId: payload.groupId,
operatorUserId: getCurrentUserId()
})
}
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 call = rtcStore.call
if (call?.room) {
try {
await leaveCall(call.room)
} catch (e) {
console.warn('[Call] leave 失败', { room: call.room }, e)
}
// 本端先行从胶囊条移除自己,免等后端 RTC_PARTICIPANT_DISCONNECTED 推回;私聊场景 store 内部 no-op整通话由 END 关掉
rtcStore.applyParticipantDisconnected({
room: call.room,
userId: getCurrentUserId(),
conversationType: call.conversationType,
groupId: call.groupId
})
}
await lk.disconnect()
rtcStore.reset()
}
/** LiveKit Room 异常断开;多见于网络中断 */
function handlePeerDisconnected() {
if (!rtcStore.isActive) {
return
}
// 给 RTC_CALL_END WebSocket 推送一个小窗口;私聊超时 / 主动挂断等场景下,后端 endSession 会先推 RTC_CALL_END
// 让前端按业务语义("对方未接听" / "已取消" 等reset避免错把业务断开 toast 成「通话已断开」
setTimeout(() => {
if (!rtcStore.isActive) {
return
}
message.warning('通话已断开')
rtcStore.reset()
}, 100)
}
// ==================== 振铃超时兜底 ====================
/** 通话存活期间INVITING / INCOMING / RUNNING周期性触发后端扫该 room 的超时 INVITING保持 timer 是为了 inviteCall 追加新人后也能覆盖;阈值由后端配置决定,前端只负责 trigger */
const { resume: resumeNoAnswerTimer, pause: pauseNoAnswerTimer } = useIntervalFn(
triggerNoAnswerCallCheck, RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS, { immediate: false }
)
watch(
() => rtcStore.isActive,
(active) => (active ? resumeNoAnswerTimer() : pauseNoAnswerTimer()),
{ immediate: true }
)
/** 本地仍有 pending 才调INVITING / RUNNING 取 call、INCOMING 取 incomingPayload接口静默错误 fire-and-forget */
function triggerNoAnswerCallCheck() {
const source = rtcStore.call ?? rtcStore.incomingPayload
if (!source?.room || !source.inviteeIds?.length) {
return
}
noAnswerCallCheck(source.room).catch(() => undefined)
}
// ==================== 设备控制 ====================
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 })
// 同步本地 inviteeIds让新成员立即作为 pending 占位出现在网格里
rtcStore.appendInvitees(userIds)
message.success('已发送邀请')
} catch (e: any) {
console.error('[Call] invite 追加失败', { room: call.room, inviteeIds: userIds }, e)
message.error(e?.msg || '添加成员失败')
}
}
</script>