gaoluyang
2 天以前 b64a0deae5b5d33f9e20671a68936b27f0b9b00b
src/views/im/home/composables/useLiveKitRoom.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,331 @@
import { computed, ref, shallowRef } from 'vue'
import {
  ConnectionQuality,
  type LocalParticipant,
  type Participant,
  type RemoteParticipant,
  Room,
  RoomEvent,
  Track,
  VideoPresets
} from 'livekit-client'
type ParticipantEventHandler = (userId: number) => void
/** LiveKit Room è¿žæŽ¥ / è®¾å¤‡ / äº‹ä»¶çš„薄封装;UI ç»„件只关心响应式状态 */
export function useLiveKitRoom() {
  /** Room å®žä¾‹ï¼›æ¨¡å—内部状态,不对外暴露,避免调用方误写 */
  const _room = shallowRef<null | Room>(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 = [...r.remoteParticipants.values()]
  }
  /** è¿žæŽ¥ LiveKit Server;audio / video æŽ§åˆ¶åˆå§‹é»˜è®¤å¼€å…³ */
  async function connect(url: string, token: string, opts: { audio?: boolean; video?: boolean }) {
    // æ–°è¿žæŽ¥å‰å…ˆæ–­å¼€æ—§ Room;保留本次注册的事件回调
    if (_room.value) {
      await disconnectRoom(false)
    }
    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 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))
      // mute / unmute è®© pickStream çš„ isMuted çŸ­è·¯é‡ç®—,video å…ƒç´ èƒ½è§£ç»‘ srcObject è€Œä¸æ˜¯å¡æœ€åŽä¸€å¸§
      .on(RoomEvent.TrackMuted, () => syncRemotes(r))
      .on(RoomEvent.TrackUnmuted, () => 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 æ¡æ‰‹å¹¶è¡Œï¼Œçœ 100~300ms ä¸²è¡Œå»¶è¿Ÿï¼›
    // æ‹¿åˆ°çš„ 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 (error) {
      // ç”¨æˆ·åœ¨æµè§ˆå™¨åŽŸç”Ÿå¯¹è¯æ¡†é‡Œå–æ¶ˆé€‰æ‹©ï¼Œä¸å½“ä½œé”™è¯¯
      screenShareEnabled.value = _room.value.localParticipant.isScreenShareEnabled
      throw error
    }
  }
  /** æ³¨å†Œã€Œè¿œç«¯è¿žæŽ¥å¼‚常断开」回调;返回反注册函数 */
  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): null | number {
    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, { stream: MediaStream; track: MediaStreamTrack; }>()
  /**
   * å–参与者指定来源的轨道并打包为 MediaStream;
   * å‚与者走 unknown æ˜¯å› ä¸ºå“åº”式系统会丢失 livekit-client çš„类签名,函数内手动 cast å›žå‚与者类型;
   * å‘½ä¸­ç¼“存返回同一 MediaStream å¼•用,下游 watch / srcObject æ— éœ€é‡æŒ‚ï¼›
   * è½¨é“被 mute(本端关摄像头 / è¿œç«¯å…³æ‘„像头)时返回 null,让 video å…ƒç´ è§£ç»‘ srcObject è€Œä¸æ˜¯å¡åœ¨æœ€åŽä¸€å¸§
   */
  function pickStream(participant: unknown, source: Track.Source): MediaStream | null {
    const p = participant as Participant
    const pub = p.getTrackPublication(source)
    if (!pub || pub.isMuted) {
      return null
    }
    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
  }
  /** æ–­å¼€å½“前 Room;clearHandlers ä¸º true æ—¶åŒæ­¥æ¸…理外部注册的事件回调 */
  async function disconnectRoom(clearHandlers: boolean) {
    // æ¸…理通话结束后不再复用的订阅回调
    if (clearHandlers) {
      disconnectedHandlers.clear()
      participantConnectedHandlers.clear()
      participantDisconnectedHandlers.clear()
    }
    // æ¸…理音视频轨道缓存
    streamCache.clear()
    if (_room.value) {
      // å¸è½½ Room äº‹ä»¶å¹¶æ–­å¼€è¿žæŽ¥
      _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
  }
  /** ä¸»åŠ¨æ–­å¼€ï¼›é€šè¯ç»“æŸç»Ÿä¸€è°ƒ */
  async function disconnect() {
    await disconnectRoom(true)
  }
  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>