| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |