feat(im): 优化 rtcStore 的命名

im
YunaiV 2026-05-14 22:15:35 +08:00
parent 4a811fb0bb
commit b455ce4949
10 changed files with 448 additions and 90 deletions

View File

@ -339,5 +339,6 @@ export enum DICT_TYPE {
IM_GROUP_MEMBER_ROLE = 'im_group_member_role', // IM 群成员角色
IM_GROUP_JOIN_TYPE = 'im_group_join_type', // IM 群加群方式
IM_GROUP_ADD_SOURCE = 'im_group_add_source', // IM 加群来源
IM_GROUP_REQUEST_HANDLE_RESULT = 'im_group_request_handle_result' // IM 加群申请处理结果
IM_GROUP_REQUEST_HANDLE_RESULT = 'im_group_request_handle_result', // IM 加群申请处理结果
IM_RTC_CALL_MEDIA_TYPE = 'im_rtc_call_media_type' // IM 通话媒体类型1=语音 / 2=视频
}

View File

@ -0,0 +1,292 @@
import { computed, ref, shallowRef } from 'vue'
import {
Room,
RoomEvent,
ConnectionQuality,
Track,
VideoPresets,
type LocalParticipant,
type Participant,
type RemoteParticipant
} from 'livekit-client'
type ParticipantEventHandler = (userId: number) => void
/** LiveKit Room 连接 / 设备 / 事件的薄封装UI 组件只关心响应式状态 */
export function useLiveKitRoom() {
/** Room 实例;模块内部状态,不对外暴露,避免调用方误写 */
const _room = shallowRef<Room | null>(null)
/** 只读 room 引用;调用方仅用于幂等判定 */
const room = computed(() => _room.value)
/** 本地参与者;连接成功后赋值 */
const localParticipant = shallowRef<LocalParticipant | null>(null)
/** 远端参与者列表ParticipantConnected / Disconnected 时刷新shallowRef 避免 Vue 深度代理 SDK class 内部 */
const remoteParticipants = shallowRef<RemoteParticipant[]>([])
/** 连接状态 */
const isConnected = ref(false)
/** 连接质量 */
const connectionQuality = ref<ConnectionQuality>(ConnectionQuality.Unknown)
/** 麦克风开关 */
const micEnabled = ref(true)
/** 摄像头开关 */
const cameraEnabled = ref(false)
/** 屏幕共享开关 */
const screenShareEnabled = ref(false)
/** 当前是否处于「重连中」;瞬断时 UI 显示提示而不强制结束通话 */
const reconnecting = ref(false)
/** 远端断开订阅者;通话结束时统一清空 */
const disconnectedHandlers = new Set<() => void>()
/** 房内某人加入订阅者;主叫端用于从 INVITING 切到 RUNNING */
const participantConnectedHandlers = new Set<ParticipantEventHandler>()
/** 房内某人离开订阅者;用于把 userId 标记为「已退出」从 pending 占位中移除 */
const participantDisconnectedHandlers = new Set<ParticipantEventHandler>()
/** 同步远端参与者列表到响应式数组 */
function syncRemotes(r: Room) {
remoteParticipants.value = Array.from(r.remoteParticipants.values())
}
/** 连接 LiveKit Serveraudio / video 控制初始默认开关 */
async function connect(url: string, token: string, opts: { audio?: boolean; video?: boolean }) {
const r = new Room({
// 按格子尺寸自动选 simulcast 层
adaptiveStream: true,
// 未订阅的层动态停发,节省上行
dynacast: true,
// 采集分辨率 720p确保大格子清晰
videoCaptureDefaults: {
resolution: VideoPresets.h720.resolution
},
// 发布编码上限 1.5 Mbps / 30fps保留默认 simulcast 三层180p / 360p / 720p
publishDefaults: {
videoEncoding: {
maxBitrate: 1_500_000,
maxFramerate: 30,
priority: 'high'
},
// 屏幕共享码率 3 Mbps文字界面清晰
screenShareEncoding: {
maxBitrate: 3_000_000,
maxFramerate: 15,
priority: 'medium'
}
}
})
_room.value = r
r.on(RoomEvent.ParticipantConnected, (rp) => {
syncRemotes(r)
const userId = parseUserId(rp.identity)
if (userId != null) {
participantConnectedHandlers.forEach((cb) => cb(userId))
}
})
.on(RoomEvent.ParticipantDisconnected, (rp) => {
syncRemotes(r)
// 离开的参与者缓存清掉,避免下次同 sid 重连命中失效引用
for (const key of Array.from(streamCache.keys())) {
if (key.startsWith(`${rp.sid}:`)) {
streamCache.delete(key)
}
}
const userId = parseUserId(rp.identity)
if (userId != null) {
participantDisconnectedHandlers.forEach((cb) => cb(userId))
}
})
.on(RoomEvent.TrackSubscribed, () => syncRemotes(r))
.on(RoomEvent.TrackUnsubscribed, () => syncRemotes(r))
.on(RoomEvent.ConnectionQualityChanged, (quality) => {
connectionQuality.value = quality
})
// 瞬断 → 显示「重连中」;不关通话窗,由 ICE restart 机制恢复
.on(RoomEvent.Reconnecting, () => {
reconnecting.value = true
})
.on(RoomEvent.Reconnected, () => {
reconnecting.value = false
})
// 重连失败 / 主动断 / 被踢时触发清理
.on(RoomEvent.Disconnected, () => {
isConnected.value = false
reconnecting.value = false
disconnectedHandlers.forEach((cb) => cb())
})
// 预热 getUserMedia 与 WebSocket 握手并行,省 100~300ms 串行延迟;
// 拿到的 stream 仅用于触发权限弹窗 + 设备就绪,握手完成后由 LiveKit 内部重新请求设备发布轨
const warmup = prewarmMedia(opts)
// 建立 WebSocket 信令 + WebRTC 媒体通道;完成后 localParticipant 可用,已在房参与者会通过 ParticipantConnected 事件批量推送
await r.connect(url, token)
localParticipant.value = r.localParticipant
isConnected.value = true
// 预热结果不直接发布(避免 SDK 与外部 track 生命周期纠缠),仅等待权限就绪后再走标准 setXxxEnabled
await warmup
// 麦克风与摄像头权限相互独立,并行启用发布
const inits: Promise<unknown>[] = []
if (opts.audio) {
inits.push(r.localParticipant.setMicrophoneEnabled(true))
}
if (opts.video) {
inits.push(r.localParticipant.setCameraEnabled(true))
}
if (inits.length > 0) {
await Promise.all(inits)
}
micEnabled.value = !!opts.audio
cameraEnabled.value = !!opts.video
// 兜底同步一次远端列表r.connect 期间 ParticipantConnected 事件可能在 handler 绑定前触发被吞,导致首屏漏人
syncRemotes(r)
}
/** 提前触发权限弹窗 + 设备唤起,串行延迟在 r.connect 期间一起跑;失败静默(连接后会再试一次) */
async function prewarmMedia(opts: { audio?: boolean; video?: boolean }): Promise<void> {
if (!opts.audio && !opts.video) {
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: !!opts.audio,
video: !!opts.video
})
// 拿到权限即可,立即停掉所有 track 释放设备;正式发布走 SDK 流程重新请求
stream.getTracks().forEach((t) => t.stop())
} catch {
// 用户拒绝 / 设备占用等异常,交给后续 setXxxEnabled 再次尝试报错
}
}
/** 切麦克风 */
async function setMicEnabled(enabled: boolean) {
if (!_room.value) {
return
}
await _room.value.localParticipant.setMicrophoneEnabled(enabled)
micEnabled.value = enabled
}
/** 切摄像头 */
async function setCameraEnabled(enabled: boolean) {
if (!_room.value) {
return
}
await _room.value.localParticipant.setCameraEnabled(enabled)
cameraEnabled.value = enabled
}
/**
*
*
* setScreenShareEnabled SDK
*/
async function setScreenShareEnabled(enabled: boolean) {
if (!_room.value) return
try {
await _room.value.localParticipant.setScreenShareEnabled(enabled)
screenShareEnabled.value = enabled
} catch (e) {
// 用户在浏览器原生对话框里取消选择,不当作错误
screenShareEnabled.value = _room.value.localParticipant.isScreenShareEnabled
throw e
}
}
/** 注册「远端连接异常断开」回调;返回反注册函数 */
function onDisconnected(cb: () => void): () => void {
disconnectedHandlers.add(cb)
return () => disconnectedHandlers.delete(cb)
}
/** 注册「房内某人加入」回调;返回反注册函数 */
function onParticipantConnected(cb: ParticipantEventHandler): () => void {
participantConnectedHandlers.add(cb)
return () => participantConnectedHandlers.delete(cb)
}
/** 注册「房内某人离开」回调;返回反注册函数 */
function onParticipantDisconnected(cb: ParticipantEventHandler): () => void {
participantDisconnectedHandlers.add(cb)
return () => participantDisconnectedHandlers.delete(cb)
}
/** identity 是后端签 token 时塞的 userId 字符串,转 number 返回;非数字(兼容性兜底)返回 null */
function parseUserId(identity: string): number | null {
const id = Number(identity)
return Number.isNaN(id) ? null : id
}
/**
* MediaStream key `${participantSid}:${source}`value `{ track, stream }`
* MediaStreamTrack MediaStream <video>.srcObject 线
* track /
*/
const streamCache = new Map<string, { track: MediaStreamTrack; stream: MediaStream }>()
/**
* MediaStream
* unknown livekit-client cast
* MediaStream watch / srcObject
*/
function pickStream(participant: unknown, source: Track.Source): MediaStream | null {
const p = participant as Participant
const pub = p.getTrackPublication(source)
const track = pub?.track?.mediaStreamTrack
if (!track) {
return null
}
const key = `${p.sid}:${source}`
const cached = streamCache.get(key)
if (cached && cached.track === track) {
return cached.stream
}
const stream = new MediaStream([track])
streamCache.set(key, { track, stream })
return stream
}
/** 主动断开;通话结束统一调 */
async function disconnect() {
disconnectedHandlers.clear()
participantConnectedHandlers.clear()
participantDisconnectedHandlers.clear()
streamCache.clear()
if (_room.value) {
// 断开前先卸事件,避免 disconnect 期间 ParticipantDisconnected / TrackUnsubscribed 仍触发 syncRemotes
_room.value.removeAllListeners()
await _room.value.disconnect()
_room.value = null
}
localParticipant.value = null
remoteParticipants.value = []
isConnected.value = false
reconnecting.value = false
micEnabled.value = true
cameraEnabled.value = false
screenShareEnabled.value = false
}
return {
room,
localParticipant,
remoteParticipants,
isConnected,
connectionQuality,
micEnabled,
cameraEnabled,
screenShareEnabled,
reconnecting,
connect,
disconnect,
setMicEnabled,
setCameraEnabled,
setScreenShareEnabled,
pickStream,
onDisconnected,
onParticipantConnected,
onParticipantDisconnected
}
}
export type ImLiveKitRoom = ReturnType<typeof useLiveKitRoom>

View File

@ -0,0 +1,21 @@
import { ref, watch, type Ref } from 'vue'
/**
* MediaStream `<video>` / `<audio>` srcObject
* stream srcObject
*/
export function useMediaStreamElement<T extends HTMLMediaElement>(
streamSource: () => MediaStream | null | undefined
): Ref<T | undefined> {
const elRef = ref<T>()
watch(
streamSource,
(stream) => {
if (elRef.value) {
elRef.value.srcObject = stream || null
}
},
{ flush: 'post', immediate: true }
)
return elRef
}

View File

@ -24,7 +24,7 @@
<ContextMenu />
<!-- 实时通话浮层监听 rtcStore 全局状态可在任意 IM 子页弹出 -->
<CallContainer />
<RtcCallContainer />
</div>
</template>
@ -50,7 +50,7 @@ import ToolBar from './components/ToolBar.vue'
import UserInfoCard from './components/user/UserInfoCard.vue'
import GroupInfoCard from './components/group/GroupInfoCard.vue'
import ContextMenu from './components/ContextMenu.vue'
import CallContainer from './components/rtc/CallContainer.vue'
import RtcCallContainer from './components/rtc/RtcCallContainer.vue'
defineOptions({ name: 'ImIndex' })

View File

@ -90,7 +90,7 @@
</div>
<!-- 群通话胶囊条仅群聊 + 该群有活跃通话时显示点击展开看成员 + 加入按钮 -->
<GroupCallBanner
<RtcGroupCallBanner
v-if="isGroup && conversationStore.activeConversation"
:group-id="conversationStore.activeConversation.targetId"
/>
@ -198,7 +198,7 @@
<GroupMuteMemberDialog ref="muteMemberDialogRef" @success="reloadGroupData" />
<!-- 群通话成员选择弹窗 -->
<CallMemberPickerDialog ref="callMemberPickerRef" @success="onCallMemberPicked" />
<RtcCallMemberPickerDialog ref="callMemberPickerRef" @success="onCallMemberPicked" />
</template>
<div
v-else
@ -239,8 +239,8 @@ import ConversationPrivateSide from '../conversation/ConversationPrivateSide.vue
import type { GroupLite } from '../../../../types'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
import GroupMuteMemberDialog from '../../../../components/group/GroupMuteMemberDialog.vue'
import CallMemberPickerDialog from '../../../../components/rtc/CallMemberPickerDialog.vue'
import GroupCallBanner from '../../../../components/rtc/GroupCallBanner.vue'
import RtcCallMemberPickerDialog from '../../../../components/rtc/RtcCallMemberPickerDialog.vue'
import RtcGroupCallBanner from '../../../../components/rtc/RtcGroupCallBanner.vue'
import { createCall } from '@/api/im/home/rtc'
import { ImRtcCallMediaType, ImRtcCallStatus, ImConversationType } from '@/views/im/utils/constants'
import { resolveCallEndReasonText } from '@/views/im/utils/message'
@ -435,7 +435,7 @@ function reloadGroupData() {
const historyDialogRef = ref<InstanceType<typeof MessageHistory>>()
const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref
const muteMemberDialogRef = ref<InstanceType<typeof GroupMuteMemberDialog>>()
const callMemberPickerRef = ref<InstanceType<typeof CallMemberPickerDialog>>()
const callMemberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>()
/** 群通话发起:成员选择弹窗打开期间临时持有的 mediaType */
const pendingMediaType = ref<number | null>(null)
@ -457,14 +457,11 @@ async function startPrivateCall(mediaType: number) {
if (!conversation) {
return
}
await doInvite(
{
conversationType: ImConversationType.PRIVATE,
mediaType,
inviteeIds: [conversation.targetId]
},
{ nickname: conversation.name, avatar: conversation.avatar }
)
await doInvite({
conversationType: ImConversationType.PRIVATE,
mediaType,
inviteeIds: [conversation.targetId]
})
}
/** 群通话入口:默认语音直接弹选人;与微信群通话一致,进通话后用户按需开摄像头 */
@ -485,36 +482,30 @@ async function onCallMemberPicked(selectedIds: number[]) {
if (!conversation || mediaType == null || selectedIds.length === 0) {
return
}
await doInvite(
{
conversationType: ImConversationType.GROUP,
mediaType,
groupId: conversation.targetId,
inviteeIds: selectedIds
},
{ nickname: conversation.name, avatar: conversation.avatar }
)
await doInvite({
conversationType: ImConversationType.GROUP,
mediaType,
groupId: conversation.targetId,
inviteeIds: selectedIds
})
}
/** 实际调 create 接口;统一处理成功 / ENDED如忙线立即结束/ 异常三种返回 */
async function doInvite(
reqVO: {
conversationType: number
mediaType: number
groupId?: number
inviteeIds: number[]
},
peer: { nickname?: string; avatar?: string }
) {
async function doInvite(reqVO: {
conversationType: number
mediaType: number
groupId?: number
inviteeIds: number[]
}) {
try {
const resp = await createCall(reqVO)
const data = await createCall(reqVO)
// INSERT + end线toast INVITING chat tip RTC_CALL_END
if (resp.status === ImRtcCallStatus.ENDED) {
message.warning(resolveCallEndReasonText(resp.endReason))
if (data.status === ImRtcCallStatus.ENDED) {
message.warning(resolveCallEndReasonText(data.endReason))
return
}
// INVITING store / RTC
rtcStore.startInviting(resp, peer)
rtcStore.startInviting(data)
} catch (e: any) {
message.error(e?.msg || '发起通话失败')
}

View File

@ -71,6 +71,13 @@ export const useGroupStore = defineStore('imGroupStore', {
(state) =>
(id: number): Group | undefined => {
return state.groups.find((g) => g.id === id)
},
/** 群成员 userId → GroupMember 索引;调用方按 userId 反查昵称 / 头像等元信息 */
getGroupMemberMap:
(state) =>
(id: number): Map<number, GroupMember> => {
const group = state.groups.find((g) => g.id === id)
return new Map((group?.members || []).map((m) => [m.userId, m]))
}
},

View File

@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { isEqual } from 'lodash-es'
import type { ImRtcCallRespVO, ImRtcGroupCallRespVO } from '@/api/im/home/rtc'
import {
ImRtcCallStage,
@ -9,6 +10,9 @@ import {
type ImRtcParticipantStatusValue,
type ImRtcCallStageValue
} from '../../utils/constants'
import { getCurrentUserId } from '../../utils/storage'
import { useFriendStore } from './friendStore'
import { useGroupStore } from './groupStore'
// RTC_CALL 通话信令载荷;按 status 区分子类型语义
export interface ImRtcCallNotification {
@ -48,7 +52,7 @@ export interface ImRtcParticipantDisconnectedNotification {
groupId?: number
}
// RTC_CALL_END 通话结束载荷(入消息流;私聊渲染准气泡,群聊渲染 tip
// RTC_CALL_END 通话结束载荷(入消息流;私聊渲染消息气泡,群聊渲染系统提示行
export interface ImRtcCallEndNotification {
room: string
conversationType: number
@ -64,17 +68,53 @@ export interface ImRtcCallEndNotification {
export const useRtcStore = defineStore('imRtc', () => {
/** 当前阶段 */
const stage = ref<ImRtcCallStageValue>(ImRtcCallStage.IDLE)
/** 当前invite / accept / refreshToken 拿到的完整信息 */
const session = ref<ImRtcCallRespVO | null>(null)
/** 当前invite / accept / refreshToken 拿到的完整信息 */
const call = ref<ImRtcCallRespVO | null>(null)
/** 来电载荷;仅 INCOMING 阶段使用status 固定 INVITING其它字段 INVITE 专属 */
const incomingPayload = ref<ImRtcCallNotification | null>(null)
/** 显示给对端的展示名(被叫端给主叫端用 / 主叫端给被叫端用) */
const peerNickname = ref<string>('')
const peerAvatar = ref<string>('')
/** 进入 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: ImRtcCallRespVO): number | undefined {
const myId = getCurrentUserId()
return c.inviterId === myId ? c.inviteeIds?.[0] : c.inviterId
}
/** 群活跃通话索引groupId -> 群通话摘要;用于群聊顶部胶囊条 */
const groupActiveCalls = ref<Map<number, ImRtcGroupCallRespVO>>(new Map())
@ -89,60 +129,57 @@ export const useRtcStore = defineStore('imRtc', () => {
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 RUNNINGCREATED INVITING
*/
function startInviting(s: ImRtcCallRespVO, peer: { nickname?: string; avatar?: string }) {
// TODO @AI是不是不叫 session还是叫 call然后 s 这个变量,是不是也改下,这个缩写有点怪;
session.value = s
peerNickname.value = peer.nickname || ''
peerAvatar.value = peer.avatar || ''
function startInviting(data: ImRtcCallRespVO) {
call.value = data
// 更新 stage 状态
if (s.conversationType === ImConversationType.GROUP) {
if (data.conversationType === ImConversationType.GROUP) {
stage.value = ImRtcCallStage.RUNNING
startedAt.value = Date.now()
return
}
stage.value =
s.status === ImRtcCallStatus.RUNNING ? ImRtcCallStage.RUNNING : ImRtcCallStage.INVITING
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) {
incomingPayload.value = payload
stage.value = ImRtcCallStage.INCOMING
peerNickname.value = payload.inviterNickname || ''
peerAvatar.value = payload.inviterAvatar || ''
}
/** 进入通话中阶段 */
// TODO @AIs 这个变量名。
function enterRunning(s: ImRtcCallRespVO) {
session.value = s
function enterRunning(data: ImRtcCallRespVO) {
call.value = data
incomingPayload.value = null
stage.value = ImRtcCallStage.RUNNING
startedAt.value = Date.now()
}
/** 重置;通话结束统一调用 */
function reset() {
stage.value = ImRtcCallStage.IDLE
session.value = null
call.value = null
incomingPayload.value = null
peerNickname.value = ''
peerAvatar.value = ''
startedAt.value = 0
leftUserIds.value = new Set()
}
/** 标记某个 userId 已退出 / 拒绝;用于 pending 占位渲染时排除 */
// TODO @AI是不是和 isUserLeft 放在一块?
function markUserLeft(userId: number) {
if (!userId || leftUserIds.value.has(userId)) {
return
}
leftUserIds.value = new Set([...leftUserIds.value, userId])
}
// ==================== 群通话胶囊条状态 ====================
/**
@ -154,10 +191,23 @@ export const useRtcStore = defineStore('imRtc', () => {
if (!payload?.groupId) {
return
}
// TODO @AI最好叫做 newXXX 之类的;
const next = new Map(groupActiveCalls.value)
next.set(payload.groupId, payload)
groupActiveCalls.value = next
// 浅比较room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
const existing = groupActiveCalls.value.get(payload.groupId)
if (existing && isSameGroupCall(existing, payload)) {
return
}
const newGroupActiveCalls = new Map(groupActiveCalls.value)
newGroupActiveCalls.set(payload.groupId, payload)
groupActiveCalls.value = newGroupActiveCalls
}
/** 两条群通话摘要内容相等room / mediaType / inviterId / 两个 userId 数组逐项相等) */
function isSameGroupCall(a: ImRtcGroupCallRespVO, b: ImRtcGroupCallRespVO): boolean {
if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) {
return false
}
return isEqual(a.joinedUserIds ?? [], b.joinedUserIds ?? []) &&
isEqual(a.inviteeIds ?? [], b.inviteeIds ?? [])
}
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
@ -165,10 +215,9 @@ export const useRtcStore = defineStore('imRtc', () => {
if (!groupId || !groupActiveCalls.value.has(groupId)) {
return
}
// TODO @AI最好叫做 newXXX 之类的;
const next = new Map(groupActiveCalls.value)
next.delete(groupId)
groupActiveCalls.value = next
const newGroupActiveCalls = new Map(groupActiveCalls.value)
newGroupActiveCalls.delete(groupId)
groupActiveCalls.value = newGroupActiveCalls
}
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */
@ -182,7 +231,8 @@ export const useRtcStore = defineStore('imRtc', () => {
if (!isGroup || !payload.groupId) {
return
}
// TODO @AI写下注释
// 胶囊条懒填充:本端可能在通话开始后才打开该群会话,没收到过 setGroupCall
// 此处用加入通知建一条最小记录inviteeIds 留空,展开 popover / 加入时再走 getActiveCall 补
const existing = groupActiveCalls.value.get(payload.groupId)
if (!existing) {
setGroupCall({
@ -198,7 +248,6 @@ export const useRtcStore = defineStore('imRtc', () => {
if (existing.room !== payload.room) {
return
}
// TODO @AI写下注释
const joined = existing.joinedUserIds ?? []
if (joined.includes(payload.userId)) {
return
@ -229,10 +278,11 @@ export const useRtcStore = defineStore('imRtc', () => {
return {
stage,
session,
call,
incomingPayload,
peerNickname,
peerAvatar,
startedAt,
isActive,
startInviting,
showIncoming,

View File

@ -795,10 +795,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
rtcStore.removeGroupCall(groupId)
}
// 通话窗 / 来电窗指向同一 room 时关闭:
// RUNNING / INVITING 阶段对比 session.roomINCOMING 阶段对比 incomingPayload.room
const matchSession = rtcStore.session?.room === payload.room
// RUNNING / INVITING 阶段对比 call.roomINCOMING 阶段对比 incomingPayload.room
const matchCall = rtcStore.call?.room === payload.room
const matchIncoming = rtcStore.incomingPayload?.room === payload.room
if (rtcStore.isActive && (matchSession || matchIncoming)) {
if (rtcStore.isActive && (matchCall || matchIncoming)) {
const reasonText = resolveCallEndReasonText(payload.endReason)
console.info('[Call] end:', reasonText)
rtcStore.reset()

View File

@ -207,7 +207,7 @@ export type ImRtcParticipantStatusValue =
* ImRtcCallStatus 1:1 stage
*/
export const ImRtcCallStage = {
IDLE: 'idle', // 空闲;后端无对应(本端无 session
IDLE: 'idle', // 空闲;后端无对应(本端无活跃通话
INVITING: 'inviting', // 主叫等待对方接受;对应后端 ImRtcCallStatus.CREATED自己是主叫
INCOMING: 'incoming', // 被叫来电响铃;对应后端 ImRtcCallStatus.CREATED自己是被叫
RUNNING: 'running' // 通话中;对应后端 ImRtcCallStatus.RUNNING

View File

@ -1,5 +1,6 @@
import { generateUUID } from '@/utils'
import { useUserStore } from '@/store/modules/user'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import {
ImRtcCallEndReason,
ImConversationType,
@ -851,11 +852,6 @@ export function parseRtcCallPayload(
return content ? parseMessage<RtcCallStartPayload & RtcCallEndPayload>(content) : null
}
/** 媒体类型文案TODO 字典化 */
function callMediaText(mediaType: number | undefined): string {
return mediaType === 2 ? '视频' : '语音'
}
/**
* segmentsRTC_CALL_START / RTC_CALL_END
* <p>
@ -872,7 +868,7 @@ export function resolveRtcCallTipSegments(message: {
if (!payload) {
return []
}
const media = callMediaText(payload.mediaType)
const media = getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, payload.mediaType)
if (message.type === ImMessageType.RTC_CALL_START) {
const inviter = payload.inviterNickname?.trim() || `用户 ${payload.inviterUserId ?? ''}`
return [tipText(`${inviter} 发起了${media}通话`)]