gaoluyang
11 小时以前 cb9cd49627b65a4c0e137e08063271a8cefe1826
src/views/im/home/components/rtc/rtc-call-container.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,488 @@
<script lang="ts" setup>
import type { CallParticipantVM } from './rtc-call-participant-tile.vue'
import { computed, ref, watch } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { message } from 'ant-design-vue'
import { Track } from 'livekit-client'
import {
  acceptCall,
  cancelCall,
  inviteCall,
  leaveCall,
  noAnswerCallCheck,
  rejectCall
} from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS } from '#/views/im/utils/config'
import {
  ImConversationType,
  ImRtcCallMediaType,
  ImRtcCallStage
} from '#/views/im/utils/constants'
import { getSenderAvatar, getSenderDisplayName } from '#/views/im/utils/user'
import { useLiveKitRoom } from '../../composables/useLiveKitRoom'
import { useRtcStore } from '../../store/rtcStore'
import RtcCallIncoming from './rtc-call-incoming.vue'
import RtcCallInviting from './rtc-call-inviting.vue'
import RtcCallMemberPickerDialog from './rtc-call-member-picker-dialog.vue'
import RtcCallRunning from './rtc-call-running.vue'
defineOptions({ name: 'ImRtcCallContainer' })
const rtcStore = useRtcStore()
const lk = useLiveKitRoom()
const memberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>()
const connecting = ref(false)
const accepting = ref(false)
const rejecting = ref(false)
const cancelling = ref(false)
const hangingUp = ref(false)
// ==================== è§†å›¾æ¨¡åž‹ ====================
/** å½“前是否视频通话 */
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[] = [{
    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 || connecting.value) {
    return
  }
  connecting.value = true
  try {
    // å…ˆæ³¨å†Œå›žè°ƒï¼Œå† 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()
    }
  } finally {
    connecting.value = false
  }
}
/** ä¸»å«ç«¯ï¼šä»Ž 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 (error) {
        console.error('[Call] connect å¤±è´¥', { room: rtcStore.call?.room }, error)
        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 (error) {
        console.error('[Call] accept connect å¤±è´¥', { room: rtcStore.call?.room }, error)
        message.error('通话连接失败')
        // åŽç«¯ accept å·²å†™ JOINED;前端连接失败需调 leave å›žæ»šï¼Œé¿å…åŽç«¯è®°å½•残留忙线
        if (rtcStore.call?.room) {
          leaveCall(rtcStore.call.room).catch(() => undefined)
        }
        rtcStore.reset()
      }
    }
  }
)
// ==================== é€šè¯ç”Ÿå‘½å‘¨æœŸ ====================
/** ä¸»å«å–消邀请 */
async function handleCancel() {
  if (cancelling.value) {
    return
  }
  cancelling.value = true
  const room = rtcStore.call?.room
  try {
    if (room) {
      await cancelCall(room)
    }
    await lk.disconnect()
    rtcStore.reset()
  } finally {
    cancelling.value = false
  }
}
/** è¢«å«æ‹’绝来电 */
async function handleReject() {
  if (rejecting.value) {
    return
  }
  rejecting.value = true
  const payload = rtcStore.incomingPayload
  try {
    if (payload?.room) {
      await rejectCall(payload.room)
      // æœ¬ç«¯å…ˆè¡Œä»Žèƒ¶å›Šæ¡ç§»é™¤è‡ªå·±ï¼Œå…ç­‰åŽç«¯ RTC_CALL(REJECTED) æŽ¨å›žï¼›ç§èŠåœºæ™¯ store å†…部 no-op
      rtcStore.applyParticipantRejected({
        room: payload.room,
        conversationType: payload.conversationType,
        groupId: payload.groupId,
        operatorUserId: getCurrentUserId()
      })
    }
    rtcStore.reset()
  } finally {
    rejecting.value = false
  }
}
/** è¢«å«æŽ¥å¬æ¥ç”µ */
async function handleAccept() {
  if (accepting.value) {
    return
  }
  const payload = rtcStore.incomingPayload
  if (!payload) return
  accepting.value = true
  try {
    const data = await acceptCall(payload.room)
    rtcStore.enterRunning(data)
  } finally {
    accepting.value = false
  }
}
/** é€šè¯ä¸­æŒ‚æ–­ */
async function handleHangup() {
  if (hangingUp.value) {
    return
  }
  hangingUp.value = true
  const call = rtcStore.call
  try {
    if (call?.room) {
      await leaveCall(call.room)
      // æœ¬ç«¯å…ˆè¡Œä»Žèƒ¶å›Šæ¡ç§»é™¤è‡ªå·±ï¼Œå…ç­‰åŽç«¯ 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()
  } finally {
    hangingUp.value = false
  }
}
/** LiveKit Room å¼‚常断开;多见于网络中断 */
function handlePeerDisconnected() {
  if (!rtcStore.isActive) {
    return
  }
  const room = rtcStore.call?.room
  // ç»™ RTC_CALL_END WebSocket æŽ¨é€ä¸€ä¸ªå°çª—口;私聊超时 / ä¸»åŠ¨æŒ‚æ–­ç­‰åœºæ™¯ä¸‹ï¼ŒåŽç«¯ endSession ä¼šå…ˆæŽ¨ RTC_CALL_END,
  // è®©å‰ç«¯æŒ‰ä¸šåŠ¡è¯­ä¹‰ï¼ˆ"对方未接听" / "已取消" ç­‰ï¼‰reset,避免错把业务断开 toast æˆã€Œé€šè¯å·²æ–­å¼€ã€
  setTimeout(() => {
    if (!rtcStore.isActive) {
      return
    }
    // ä¸ŠæŠ¥ç¦»å¼€æˆ¿é—´
    if (room) {
      leaveCall(room).catch(() => undefined)
    }
    // æ¸…理本地通话状态
    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 (error: any) {
    // ç”¨æˆ·å–消选择,不当作错误;其它异常打日志
    if (error?.name !== 'NotAllowedError' && error?.message !== 'permission denied') {
      console.warn('[Call] screenShare åˆ‡æ¢å¤±è´¥', { enabled }, error)
    }
  }
}
// ==================== æ·»åŠ æˆå‘˜ ====================
/** æ‰“开「添加成员」弹窗;占位群通话 + æŽ¥é€šä¸­çŠ¶æ€æ‰å…è®¸ */
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
  }
  await inviteCall({ room: call.room, inviteeIds: userIds })
  // åŒæ­¥æœ¬åœ° inviteeIds,让新成员立即作为 pending å ä½å‡ºçŽ°åœ¨ç½‘æ ¼é‡Œ
  rtcStore.appendInvitees(userIds)
  message.success('已发送邀请')
}
</script>
<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"
      :accepting="accepting"
      :rejecting="rejecting"
      @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"
      :hanging-up="hangingUp"
      @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>