From b455ce4949f4aabe085f57611804b4af8cf0dfec Mon Sep 17 00:00:00 2001 From: YunaiV Date: Thu, 14 May 2026 22:15:35 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E4=BC=98=E5=8C=96=20rt?= =?UTF-8?q?cStore=20=E7=9A=84=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/dict.ts | 3 +- .../im/home/composables/useLiveKitRoom.ts | 292 ++++++++++++++++++ .../home/composables/useMediaStreamElement.ts | 21 ++ src/views/im/home/index.vue | 4 +- .../components/message/MessagePanel.vue | 61 ++-- src/views/im/home/store/groupStore.ts | 7 + src/views/im/home/store/rtcStore.ts | 134 +++++--- src/views/im/home/store/websocketStore.ts | 6 +- src/views/im/utils/constants.ts | 2 +- src/views/im/utils/message.ts | 8 +- 10 files changed, 448 insertions(+), 90 deletions(-) create mode 100644 src/views/im/home/composables/useLiveKitRoom.ts create mode 100644 src/views/im/home/composables/useMediaStreamElement.ts diff --git a/src/utils/dict.ts b/src/utils/dict.ts index 990cf78fe..576f8d695 100644 --- a/src/utils/dict.ts +++ b/src/utils/dict.ts @@ -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=视频 } diff --git a/src/views/im/home/composables/useLiveKitRoom.ts b/src/views/im/home/composables/useLiveKitRoom.ts new file mode 100644 index 000000000..2b0fd5cd3 --- /dev/null +++ b/src/views/im/home/composables/useLiveKitRoom.ts @@ -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(null) + /** 只读 room 引用;调用方仅用于幂等判定 */ + const room = computed(() => _room.value) + /** 本地参与者;连接成功后赋值 */ + const localParticipant = shallowRef(null) + /** 远端参与者列表;ParticipantConnected / Disconnected 时刷新;shallowRef 避免 Vue 深度代理 SDK class 内部 */ + const remoteParticipants = shallowRef([]) + /** 连接状态 */ + const isConnected = ref(false) + /** 连接质量 */ + const connectionQuality = ref(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() + /** 房内某人离开订阅者;用于把 userId 标记为「已退出」从 pending 占位中移除 */ + const participantDisconnectedHandlers = new Set() + + /** 同步远端参与者列表到响应式数组 */ + 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[] = [] + 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 { + 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,避免