461 lines
15 KiB
Vue
461 lines
15 KiB
Vue
<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>
|