✨ feat(im): 优化 rtcStore 的命名
parent
4a811fb0bb
commit
b455ce4949
|
|
@ -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=视频
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 Server;audio / 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || '发起通话失败')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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(已加入已有通话场景)→ RUNNING;CREATED → 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 @AI:s 这个变量名。
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -795,10 +795,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
rtcStore.removeGroupCall(groupId)
|
||||
}
|
||||
// 通话窗 / 来电窗指向同一 room 时关闭:
|
||||
// RUNNING / INVITING 阶段对比 session.room;INCOMING 阶段对比 incomingPayload.room
|
||||
const matchSession = rtcStore.session?.room === payload.room
|
||||
// RUNNING / INVITING 阶段对比 call.room;INCOMING 阶段对比 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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ? '视频' : '语音'
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话内通话事件 segments(RTC_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}通话`)]
|
||||
|
|
|
|||
Loading…
Reference in New Issue