| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { ImRtcApi } from '#/api/im/rtc' |
| | | |
| | | import { computed, ref } from 'vue' |
| | | |
| | | import { defineStore } from 'pinia' |
| | | |
| | | import { getCurrentUserId } from '#/views/im/utils/auth' |
| | | |
| | | import { |
| | | ImConversationType, |
| | | type ImRtcCallEndReasonValue, |
| | | ImRtcCallStage, |
| | | type ImRtcCallStageValue, |
| | | ImRtcCallStatus, |
| | | type ImRtcParticipantStatusValue |
| | | } from '../../utils/constants' |
| | | import { useFriendStore } from './friendStore' |
| | | import { useGroupStore } from './groupStore' |
| | | |
| | | type GroupActiveCallCache = { |
| | | participantsLoaded?: boolean // æ¯å¦å·²æå宿´åä¸è
å表 |
| | | } & ImRtcApi.RtcGroupCallRespVO |
| | | |
| | | // RTC_CALL éè¯ä¿¡ä»¤è½½è·ï¼æ status åºååç±»åè¯ä¹ |
| | | export interface ImRtcCallNotification { |
| | | status: ImRtcParticipantStatusValue |
| | | room: string |
| | | conversationType: number |
| | | mediaType: number |
| | | groupId?: number |
| | | // INVITE ä¸å±ï¼è¢«å«æ¥ééè¦ç LiveKit è¿æ¥åæ° + 主å«å±ç¤ºä¿¡æ¯ |
| | | livekitUrl?: string |
| | | token?: string |
| | | inviterUserId?: number |
| | | inviterNickname?: string |
| | | inviterAvatar?: string |
| | | // INVITE ä¸å±ï¼æ¬æ¬¡è¢«é请人å表ï¼å
嫿¶ä»¶äººèªèº«ï¼å端æ¥çµå°æ¡æéè¿æ»¤å±ç¤ºãé请çå
¶ä»äººã |
| | | inviteeIds?: number[] |
| | | // REJECT ä¸å±ï¼æä½è
å±ç¤ºä¿¡æ¯ï¼å
¶å®åç±»åèµ° RTC_CALL_ENDï¼ |
| | | operatorUserId?: number |
| | | operatorNickname?: string |
| | | operatorAvatar?: string |
| | | } |
| | | |
| | | // RTC_PARTICIPANT_CONNECTED éè¯åä¸è
å å
¥è½½è·ï¼LiveKit webhook 转æ¨ï¼ |
| | | export interface ImRtcParticipantConnectedNotification { |
| | | room: string |
| | | userId: number |
| | | conversationType: number |
| | | groupId?: number |
| | | // 群èåºæ¯éé请æå馿¬¡å¡«å
è¶åæ¡ç¨ |
| | | mediaType?: number |
| | | inviterUserId?: number |
| | | } |
| | | |
| | | // RTC_PARTICIPANT_DISCONNECTED éè¯åä¸è
离å¼è½½è·ï¼LiveKit webhook 转æ¨ï¼ |
| | | export interface ImRtcParticipantDisconnectedNotification { |
| | | room: string |
| | | userId: number |
| | | conversationType: number |
| | | groupId?: number |
| | | } |
| | | |
| | | // RTC_CALL_END éè¯ç»æè½½è·ï¼å
¥æ¶æ¯æµï¼ç§èæ¸²ææ¶æ¯æ°æ³¡ï¼ç¾¤è渲æç³»ç»æç¤ºè¡ï¼ |
| | | export interface ImRtcCallEndNotification { |
| | | room: string |
| | | conversationType: number |
| | | mediaType: number |
| | | endReason: ImRtcCallEndReasonValue |
| | | durationSeconds?: number |
| | | // æä½è
èååæ®µï¼HANGUP/CANCEL/REJECT 触å人ï¼webhook å
åºä¸º null |
| | | operatorUserId?: number |
| | | operatorNickname?: string |
| | | operatorAvatar?: string |
| | | } |
| | | |
| | | export const useRtcStore = defineStore('imRtc', () => { |
| | | /** å½åé¶æ®µ */ |
| | | const stage = ref<ImRtcCallStageValue>(ImRtcCallStage.IDLE) |
| | | /** å½åéè¯ï¼invite / accept / refreshToken æ¿å°ç宿´ä¿¡æ¯ */ |
| | | const call = ref<ImRtcApi.RtcCallRespVO | null>(null) |
| | | /** æ¥çµè½½è·ï¼ä»
INCOMING é¶æ®µä½¿ç¨ï¼status åºå® INVITINGï¼å
¶å®å段 INVITE ä¸å± */ |
| | | const incomingPayload = ref<ImRtcCallNotification | null>(null) |
| | | /** è¿å
¥ RUNNING çæ¶é´æ³ï¼ç¨äºéè¯æ¶é¿å±ç¤ºï¼reset æ¶æ¸
é¶ */ |
| | | const startedAt = ref(0) |
| | | |
| | | /** æ¯å¦å¤äºéè¯ç¸å
³é¶æ®µ */ |
| | | const isActive = computed(() => stage.value !== ImRtcCallStage.IDLE) |
| | | |
| | | /** |
| | | * 对端å±ç¤ºåï¼æé¶æ®µ + ä¼è¯ç±»ååæ¯ï¼ |
| | | * INCOMING 忥çµè½½è·ç inviterNicknameï¼ç¾¤éè¯å群åï¼ç§èæ¥ friendStore åæ¥å¯¹ç«¯ userId |
| | | */ |
| | | const peerNickname = computed<string>(() => { |
| | | if (stage.value === ImRtcCallStage.INCOMING) { |
| | | return incomingPayload.value?.inviterNickname || '' |
| | | } |
| | | const c = call.value |
| | | if (!c) return '' |
| | | if (c.conversationType === ImConversationType.GROUP) { |
| | | return useGroupStore().getGroup(c.groupId ?? 0)?.name || '' |
| | | } |
| | | const peerUserId = resolvePrivatePeerUserId(c) |
| | | return (peerUserId && useFriendStore().getFriend(peerUserId)?.nickname) || '' |
| | | }) |
| | | |
| | | /** 对端头åï¼çç¥å peerNickname */ |
| | | const peerAvatar = computed<string>(() => { |
| | | if (stage.value === ImRtcCallStage.INCOMING) { |
| | | return incomingPayload.value?.inviterAvatar || '' |
| | | } |
| | | const c = call.value |
| | | if (!c) return '' |
| | | if (c.conversationType === ImConversationType.GROUP) { |
| | | return useGroupStore().getGroup(c.groupId ?? 0)?.avatar || '' |
| | | } |
| | | const peerUserId = resolvePrivatePeerUserId(c) |
| | | return (peerUserId && useFriendStore().getFriend(peerUserId)?.avatar) || '' |
| | | }) |
| | | |
| | | /** ç§èåºæ¯å¯¹ç«¯ userIdï¼èªå·±æ¯ä¸»å«ååé¦ä¸ª inviteeï¼å¦åå inviter */ |
| | | function resolvePrivatePeerUserId(c: ImRtcApi.RtcCallRespVO): number | undefined { |
| | | const myId = getCurrentUserId() |
| | | return c.inviterId === myId ? c.inviteeIds?.[0] : c.inviterId |
| | | } |
| | | |
| | | /** 群活è·éè¯ç´¢å¼ï¼groupId -> 群éè¯æè¦ï¼ç¨äºç¾¤èé¡¶é¨è¶åæ¡ */ |
| | | const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map()) |
| | | |
| | | /** |
| | | * å·²éåº / å·²æç»çç¨æ·ç¼å·éåï¼ç¾¤éè¯åºæ¯å
pending å 使¸²ææ¶æé¤ï¼ |
| | | * æ¥æºï¼åä¸è
离å¼éç¥ + 群éè¯å人æç»ç operatorUserIdï¼éè¯ç»æï¼resetï¼æ¶æ¸
空 |
| | | */ |
| | | const leftUserIds = ref<Set<number>>(new Set()) |
| | | |
| | | /** æ¯å¦å·²è®°å½æ userId å·²éåº / æç» */ |
| | | function isUserLeft(userId: number): boolean { |
| | | return leftUserIds.value.has(userId) |
| | | } |
| | | |
| | | /** æ è®°æä¸ª userId å·²éåº / æç»ï¼ç¨äº pending å 使¸²ææ¶æé¤ */ |
| | | function markUserLeft(userId: number) { |
| | | if (!userId || leftUserIds.value.has(userId)) { |
| | | return |
| | | } |
| | | leftUserIds.value = new Set([...leftUserIds.value, userId]) |
| | | } |
| | | |
| | | /** |
| | | * 主å«åèµ·éè¯ï¼æä¼è¯ç±»å + status å³å® stageï¼ |
| | | * 群éè¯ï¼åèµ·äººç´æ¥è¿ RUNNING å¤äººå¡çè§å¾ï¼æ¿å
å¯è½åªæèªå·±ï¼çå
¶ä»äººéç»å å
¥ï¼ |
| | | * ç§èï¼æ status èµ°ï¼RUNNINGï¼å·²å å
¥å·²æéè¯åºæ¯ï¼â RUNNINGï¼CREATED â INVITING çè¢«å«æ¥é |
| | | */ |
| | | function startInviting(data: ImRtcApi.RtcCallRespVO) { |
| | | call.value = data |
| | | // 群éè¯åºæ¯åå
¥æ¬å°è¶åæ¡ç¼å |
| | | syncGroupActiveCall(data) |
| | | // æ´æ° stage ç¶æ |
| | | if (data.conversationType === ImConversationType.GROUP) { |
| | | stage.value = ImRtcCallStage.RUNNING |
| | | startedAt.value = Date.now() |
| | | return |
| | | } |
| | | const running = data.status === ImRtcCallStatus.RUNNING |
| | | stage.value = running ? ImRtcCallStage.RUNNING : ImRtcCallStage.INVITING |
| | | if (running) { |
| | | startedAt.value = Date.now() |
| | | } |
| | | } |
| | | |
| | | /** è¢«å«æ¶å°æ¥çµï¼åå° INCOMINGï¼æ¥æ¶ RTC_CALL(INVITE) payload */ |
| | | function showIncoming(payload: ImRtcCallNotification) { |
| | | if (isActive.value) { |
| | | return |
| | | } |
| | | incomingPayload.value = payload |
| | | stage.value = ImRtcCallStage.INCOMING |
| | | // æ inviter å
åºé¦æ¬¡å¡«å
è¶åæ¡ |
| | | syncGroupActiveCall({ |
| | | conversationType: payload.conversationType, |
| | | room: payload.room, |
| | | groupId: payload.groupId, |
| | | mediaType: payload.mediaType, |
| | | inviterId: payload.inviterUserId ?? 0, |
| | | joinedUserIds: payload.inviterUserId ? [payload.inviterUserId] : [], |
| | | inviteeIds: payload.inviteeIds |
| | | }) |
| | | } |
| | | |
| | | /** è¿å
¥éè¯ä¸é¶æ®µ */ |
| | | function enterRunning(data: ImRtcApi.RtcCallRespVO) { |
| | | call.value = data |
| | | // ç¦»å¼ INCOMING é¶æ®µï¼æ¸
空æ¥çµè½½è· |
| | | incomingPayload.value = null |
| | | stage.value = ImRtcCallStage.RUNNING |
| | | startedAt.value = Date.now() |
| | | // æ¥éåç¨ RespVO 宿´è¦çè¶åæ¡ |
| | | syncGroupActiveCall(data) |
| | | } |
| | | |
| | | /** |
| | | * 群éè¯åºæ¯åæ¥æ¬å° groupActiveCalls ç¼åï¼é群éè¯æç¼º groupId ç´æ¥è¿åï¼ |
| | | * ä¸ä¾èµå端 webhook æ¨éç RTC_PARTICIPANT_CONNECTED 馿¬¡å¡«å
ï¼é¿å
è¶åæ¡åºç°å»¶è¿ï¼ |
| | | * 被å«åºæ¯éç¥è½½è·æ joinedUserIdsï¼è°ç¨æ¹æä¸»å«äººå
åºï¼åç» getActiveCall / åä¸è
äºä»¶å·æ°æå®æ´å表 |
| | | */ |
| | | function syncGroupActiveCall(input: { |
| | | conversationType: number |
| | | groupId?: number |
| | | inviteeIds?: number[] |
| | | inviterId: number |
| | | joinedUserIds?: number[] |
| | | mediaType: number |
| | | room: string |
| | | }) { |
| | | if (input.conversationType !== ImConversationType.GROUP || !input.groupId) { |
| | | return |
| | | } |
| | | // åå
¥ææ´æ°ç¾¤æ´»è·éè¯ç¼å |
| | | setGroupCall({ |
| | | room: input.room, |
| | | groupId: input.groupId, |
| | | mediaType: input.mediaType, |
| | | inviterId: input.inviterId, |
| | | joinedUserIds: input.joinedUserIds ?? [], |
| | | inviteeIds: input.inviteeIds ?? [] |
| | | }) |
| | | } |
| | | |
| | | /** éç½®ï¼éè¯ç»æç»ä¸è°ç¨ */ |
| | | function reset() { |
| | | stage.value = ImRtcCallStage.IDLE |
| | | call.value = null |
| | | incomingPayload.value = null |
| | | startedAt.value = 0 |
| | | leftUserIds.value = new Set() |
| | | } |
| | | |
| | | /** éè¯ä¸è¿½å 被é请人ï¼è®© participants ç½æ ¼åºç° pending å ä½ãè¶åæ¡åæ¥æ´æ° */ |
| | | function appendInvitees(userIds: number[]) { |
| | | if (!call.value || userIds.length === 0) { |
| | | return |
| | | } |
| | | const existing = call.value.inviteeIds ?? [] |
| | | const merged = [...new Set([...existing, ...userIds])] |
| | | if (merged.length === existing.length) { |
| | | return |
| | | } |
| | | call.value = { ...call.value, inviteeIds: merged } |
| | | syncGroupActiveCall(call.value) |
| | | } |
| | | |
| | | // ==================== 群éè¯è¶åæ¡ç¶æ ==================== |
| | | |
| | | /** |
| | | * 群éè¯å¼å§ / ç¶æå·æ°ï¼åå
¥ / æ´æ° groupActiveCallsï¼å±ç¤ºç¨ãè¶åæ¡ãï¼ |
| | | * æ¿å
æååæ¥äº¤ç» LiveKit 客æ·ç«¯äºä»¶ï¼ParticipantConnected / Disconnectedï¼ï¼ |
| | | * è¶åæ¡ä¸å®æ¶å·æ° joinedUserIds / inviteeIdsï¼å±å¼ / å å
¥æ¶åèµ° getActiveCall æ¥å£æææ° |
| | | */ |
| | | function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO, participantsLoaded?: boolean) { |
| | | if (!payload?.groupId) { |
| | | return |
| | | } |
| | | useGroupStore().markGroupActiveCallLoaded(payload.groupId) |
| | | // æµ
æ¯è¾ï¼room / mediaType / joinedUserIds / inviteeIds 齿²¡å就跳è¿ï¼é¿å
䏿¸¸ watcher æ æä¹éç® |
| | | const existing = groupActiveCalls.value.get(payload.groupId) |
| | | const nextParticipantsLoaded = |
| | | participantsLoaded ?? (existing?.room === payload.room && !!existing.participantsLoaded) |
| | | if ( |
| | | existing && |
| | | isSameGroupCall(existing, payload) && |
| | | !!existing.participantsLoaded === nextParticipantsLoaded |
| | | ) { |
| | | return |
| | | } |
| | | const newGroupActiveCalls = new Map(groupActiveCalls.value) |
| | | newGroupActiveCalls.set(payload.groupId, { |
| | | ...payload, |
| | | participantsLoaded: nextParticipantsLoaded |
| | | }) |
| | | groupActiveCalls.value = newGroupActiveCalls |
| | | } |
| | | |
| | | /** æ¸
空æå®ç¾¤çéè¯ç¼å */ |
| | | function clearGroupCallCache(groupId?: number) { |
| | | if (!groupId) { |
| | | groupActiveCalls.value = new Map() |
| | | return |
| | | } |
| | | const next = new Map(groupActiveCalls.value) |
| | | next.delete(groupId) |
| | | groupActiveCalls.value = next |
| | | } |
| | | |
| | | /** å¤æç¾¤éè¯æ¯å¦å·²è¡¥é½ */ |
| | | function isGroupCallParticipantsLoaded(groupId: number, room?: string): boolean { |
| | | const call = groupActiveCalls.value.get(groupId) |
| | | return ( |
| | | !!groupId && |
| | | !!room && |
| | | !!call && |
| | | call.room === room && |
| | | !!call.participantsLoaded |
| | | ) |
| | | } |
| | | |
| | | /** 两æ¡ç¾¤éè¯æè¦å
容ç¸çï¼room / mediaType / inviterId / 两个 userId æ°ç»é项ç¸çï¼ */ |
| | | function isSameGroupCall(a: ImRtcApi.RtcGroupCallRespVO, b: ImRtcApi.RtcGroupCallRespVO): boolean { |
| | | if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) { |
| | | return false |
| | | } |
| | | return isSameNumberList(a.joinedUserIds ?? [], b.joinedUserIds ?? []) && |
| | | isSameNumberList(a.inviteeIds ?? [], b.inviteeIds ?? []) |
| | | } |
| | | |
| | | /** å¤æä¸¤ä¸ªç¨æ·ç¼å·å表æ¯å¦é项ç¸ç */ |
| | | function isSameNumberList(a: number[], b: number[]): boolean { |
| | | if (a.length !== b.length) { |
| | | return false |
| | | } |
| | | return a.every((item, index) => item === b[index]) |
| | | } |
| | | |
| | | /** 群éè¯ç»æï¼ä» groupActiveCalls ç§»é¤ï¼è¶åæ¡æ¶å¤± */ |
| | | function removeGroupCall(groupId: number, room?: string) { |
| | | if (!groupId) { |
| | | return |
| | | } |
| | | const existing = groupActiveCalls.value.get(groupId) |
| | | if (room && existing?.room !== room) { |
| | | return |
| | | } |
| | | clearGroupCallCache(groupId) |
| | | useGroupStore().markGroupActiveCallLoaded(groupId) |
| | | } |
| | | |
| | | /** è·å群å½åæ´»è·éè¯ï¼ç¨äºè¶åæ¡æ groupId æ¥è¯¢ */ |
| | | function getGroupCall(groupId: number): ImRtcApi.RtcGroupCallRespVO | undefined { |
| | | return groupActiveCalls.value.get(groupId) |
| | | } |
| | | |
| | | /** éè¯åä¸è
å å
¥ï¼æ userId å è¿ joinedUserIdsï¼ç¾¤èåºæ¯æ æ´»è·è®°å½æ¶é¦æ¬¡å¡«å
è¶åæ¡ */ |
| | | function applyParticipantConnected(payload: ImRtcParticipantConnectedNotification) { |
| | | const isGroup = payload.conversationType === ImConversationType.GROUP |
| | | if (!isGroup || !payload.groupId) { |
| | | return |
| | | } |
| | | // è¶åæ¡æå¡«å
ï¼æ¬ç«¯å¯è½å¨éè¯å¼å§åææå¼è¯¥ç¾¤ä¼è¯ï¼æ²¡æ¶å°è¿ setGroupCallï¼ |
| | | // æ¤å¤ç¨å å
¥éç¥å»ºä¸æ¡æå°è®°å½ï¼inviteeIds ç空ï¼å±å¼ popover / å å
¥æ¶åèµ° getActiveCall è¡¥ |
| | | const existing = groupActiveCalls.value.get(payload.groupId) |
| | | if (!existing) { |
| | | setGroupCall({ |
| | | room: payload.room, |
| | | groupId: payload.groupId, |
| | | mediaType: payload.mediaType ?? 0, |
| | | inviterId: payload.inviterUserId ?? 0, |
| | | joinedUserIds: [payload.userId], |
| | | inviteeIds: [] |
| | | }) |
| | | return |
| | | } |
| | | if (existing.room !== payload.room) { |
| | | return |
| | | } |
| | | const joined = existing.joinedUserIds ?? [] |
| | | if (joined.includes(payload.userId)) { |
| | | return |
| | | } |
| | | setGroupCall({ ...existing, joinedUserIds: [...joined, payload.userId] }) |
| | | } |
| | | |
| | | /** éè¯åä¸è
离å¼ï¼ä» joinedUserIds ç§»é¤ï¼åæ¶æ è®° leftUserIdsï¼pending å 使¸²ææé¤ï¼ */ |
| | | function applyParticipantDisconnected(payload: ImRtcParticipantDisconnectedNotification) { |
| | | markUserLeft(payload.userId) |
| | | const isGroup = payload.conversationType === ImConversationType.GROUP |
| | | if (!isGroup || !payload.groupId) { |
| | | return |
| | | } |
| | | dropFromGroupActiveCall(payload.groupId, payload.room, payload.userId) |
| | | } |
| | | |
| | | /** 群éè¯å人æç»éè¯·ï¼æ è®° leftUserIds + ä»è¶åæ¡ inviteeIds ç§»é¤ï¼ç§èæç»èµ° RTC_CALL_ENDï¼ä¸å
¥æ¬ééï¼ */ |
| | | function applyParticipantRejected( |
| | | payload: Pick<ImRtcCallNotification, 'conversationType' | 'groupId' | 'operatorUserId' | 'room'> |
| | | ) { |
| | | if (!payload.operatorUserId) { |
| | | return |
| | | } |
| | | markUserLeft(payload.operatorUserId) |
| | | if (payload.conversationType === ImConversationType.GROUP && payload.groupId) { |
| | | dropFromGroupActiveCall(payload.groupId, payload.room, payload.operatorUserId) |
| | | } |
| | | } |
| | | |
| | | /** 群éè¯å人æ¯éè¶
æ¶ï¼å¯¹ banner çå¤ç䏿æ¥ä¸è´ï¼è¯ä¹ç¬ç«ãå®ç°å
±äº«ï¼ */ |
| | | function applyParticipantNoAnswer( |
| | | payload: Pick<ImRtcCallNotification, 'conversationType' | 'groupId' | 'operatorUserId' | 'room'> |
| | | ) { |
| | | applyParticipantRejected(payload) |
| | | } |
| | | |
| | | /** 仿å®ç¾¤æ´»è·éè¯ç joined / pending å表éåæ¥ç§»é¤æç¨æ·ï¼ç¨äº disconnect / reject 让è¶åæ¡ä¸åå±ç¤º */ |
| | | function dropFromGroupActiveCall(groupId: number, room: string, userId: number) { |
| | | const existing = groupActiveCalls.value.get(groupId) |
| | | if (!existing || existing.room !== room) { |
| | | return |
| | | } |
| | | const joined = existing.joinedUserIds ?? [] |
| | | const invitee = existing.inviteeIds ?? [] |
| | | const nextJoined = joined.filter((id) => id !== userId) |
| | | const nextInvitee = invitee.filter((id) => id !== userId) |
| | | if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) { |
| | | return |
| | | } |
| | | if (nextJoined.length === 0 && nextInvitee.length === 0) { |
| | | removeGroupCall(groupId, room) |
| | | return |
| | | } |
| | | setGroupCall({ |
| | | ...existing, |
| | | joinedUserIds: nextJoined, |
| | | inviteeIds: nextInvitee |
| | | }) |
| | | } |
| | | |
| | | return { |
| | | stage, |
| | | call, |
| | | incomingPayload, |
| | | peerNickname, |
| | | peerAvatar, |
| | | startedAt, |
| | | isActive, |
| | | startInviting, |
| | | showIncoming, |
| | | enterRunning, |
| | | reset, |
| | | appendInvitees, |
| | | markUserLeft, |
| | | isUserLeft, |
| | | setGroupCall, |
| | | isGroupCallParticipantsLoaded, |
| | | clearGroupCallCache, |
| | | removeGroupCall, |
| | | getGroupCall, |
| | | applyParticipantConnected, |
| | | applyParticipantDisconnected, |
| | | applyParticipantRejected, |
| | | applyParticipantNoAnswer |
| | | } |
| | | }) |