admin-vue3/src/views/im/home/composables/useLiveKitRoom.ts

310 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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)
/** 扬声器开关;浏览器无系统级 API通过 audio 元素 muted 属性实现远端音频静音 */
const speakerEnabled = ref(true)
/** 屏幕共享开关 */
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
})
// 瞬断 → 显示「重连中」;不关通话窗,由 SDK 内部重连机制恢复
.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 握手并行,省 100300ms 串行延迟;
// 拿到的 stream 仅用于触发权限弹窗 + 设备就绪,握手完成后由 LiveKit 内部重新请求设备发布轨
const warmup = prewarmMedia(opts)
// 建立 WebSocket 信令 + WebRTC 媒体通道;完成后 localParticipant 可用,已在房参与者会通过 ParticipantConnected 事件批量推送
await r.connect(url, token)
// 期间被外部 disconnect 替换;中止后续 publish避免摄像头被重新启用
if (_room.value !== r) {
return
}
localParticipant.value = r.localParticipant
isConnected.value = true
// 预热结果不直接发布(避免 SDK 与外部 track 生命周期纠缠),仅等待权限就绪后再走标准 setXxxEnabled
await warmup
if (_room.value !== r) {
return
}
// 麦克风与摄像头权限相互独立,并行启用发布
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
}
/** 切扬声器;仅切响应式状态,实际静音由模板上 audio 元素 :muted 绑定生效 */
function setSpeakerEnabled(enabled: boolean) {
speakerEnabled.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
speakerEnabled.value = true
screenShareEnabled.value = false
}
return {
room,
localParticipant,
remoteParticipants,
isConnected,
connectionQuality,
micEnabled,
cameraEnabled,
speakerEnabled,
screenShareEnabled,
reconnecting,
connect,
disconnect,
setMicEnabled,
setCameraEnabled,
setSpeakerEnabled,
setScreenShareEnabled,
pickStream,
onDisconnected,
onParticipantConnected,
onParticipantDisconnected
}
}
export type ImLiveKitRoom = ReturnType<typeof useLiveKitRoom>