520 lines
18 KiB
JavaScript
520 lines
18 KiB
JavaScript
/**
|
||
* mediasoup-client 封装层
|
||
*
|
||
* 职责:
|
||
* - 封装 Device/sendTransport/recvTransport/Producer/Consumer 的生命周期
|
||
* - 把 Transport 的 connect/produce 回调桥接到 WS 信令(sendWithAck)
|
||
* - 对上层(store/meeting.js)暴露同步友好的异步 API
|
||
*
|
||
* 平台约束(Task 9 决策 Q1=a1_h5_only):
|
||
* - mediasoup-client 仅在 H5 端可用;非 H5 平台调用时会在构造时抛 ERR_PLATFORM
|
||
* - import 语句通过 uni-app 条件编译注释限定在 H5 构建
|
||
*
|
||
* 使用方式(仅限 store/meeting.js):
|
||
* import { createMediaEngine } from '@/utils/mediasoup-client'
|
||
* const engine = markRaw(createMediaEngine({ roomCode, userId, sendWithAck }))
|
||
* await engine.loadDevice(rtpCapabilities)
|
||
* const sendTransport = await engine.ensureSendTransport()
|
||
* const producer = await engine.produce({ kind: 'audio', track })
|
||
*/
|
||
|
||
// #ifdef H5
|
||
import { Device } from 'mediasoup-client'
|
||
// #endif
|
||
|
||
import {
|
||
MEETING_WS_TRANSPORT_CREATE,
|
||
MEETING_WS_TRANSPORT_CONNECT,
|
||
MEETING_WS_PRODUCE_START,
|
||
MEETING_WS_CONSUME_START,
|
||
MEETING_WS_CONSUME_RESUME,
|
||
MEETING_WS_PRODUCER_CLOSE
|
||
} from '@/constants/meeting'
|
||
|
||
/**
|
||
* 创建 MediaEngine 实例
|
||
*
|
||
* @param {Object} options
|
||
* @param {string} options.roomCode - 会议号
|
||
* @param {number} options.userId - 当前用户 ID
|
||
* @param {Function} options.sendWithAck - WS 发送带 ACK 等待的方法(由 services/websocket.js 提供)
|
||
* @param {Function} [options.logger] - 日志函数,默认 console.log
|
||
* @returns {Object} MediaEngine 实例
|
||
*/
|
||
export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defaultLogger }) {
|
||
if (!roomCode) throw new Error('[MediaEngine] roomCode 不能为空')
|
||
if (!userId) throw new Error('[MediaEngine] userId 不能为空')
|
||
if (typeof sendWithAck !== 'function') {
|
||
throw new Error('[MediaEngine] sendWithAck 必须是函数')
|
||
}
|
||
|
||
// #ifndef H5
|
||
throw new Error('[MediaEngine] 当前平台不支持 mediasoup-client(仅支持 H5)')
|
||
// #endif
|
||
|
||
// #ifdef H5
|
||
/** mediasoup Device 实例;Device.load 后持有 Router rtpCapabilities */
|
||
let device = null
|
||
|
||
/** 发送 Transport(本地 Producer 用),延迟创建,复用 */
|
||
let sendTransport = null
|
||
|
||
/** 接收 Transport(本地 Consumer 用),延迟创建,复用 */
|
||
let recvTransport = null
|
||
|
||
/** 本地 Producer 索引 Map<producerId, Producer> */
|
||
const producers = new Map()
|
||
|
||
/** 本地 Consumer 索引 Map<consumerId, Consumer> */
|
||
const consumers = new Map()
|
||
|
||
/** 是否已关闭,防止重复调用 close */
|
||
let closed = false
|
||
|
||
const ensureNotClosed = () => {
|
||
if (closed) {
|
||
throw new Error('[MediaEngine] 已关闭')
|
||
}
|
||
}
|
||
|
||
const ensureDeviceLoaded = () => {
|
||
ensureNotClosed()
|
||
if (!device || !device.loaded) {
|
||
throw new Error('[MediaEngine] Device 尚未 load')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加载 Device
|
||
* @param {Object} routerRtpCapabilities - 后端 JoinRoom 响应中的 rtp_capabilities
|
||
*/
|
||
const loadDevice = async (routerRtpCapabilities) => {
|
||
ensureNotClosed()
|
||
if (device && device.loaded) {
|
||
logger('debug', '[MediaEngine] Device 已 load,跳过')
|
||
return
|
||
}
|
||
if (!routerRtpCapabilities) {
|
||
throw new Error('[MediaEngine] routerRtpCapabilities 不能为空')
|
||
}
|
||
device = new Device()
|
||
await device.load({ routerRtpCapabilities })
|
||
logger('info', '[MediaEngine] Device load 成功', { canProduceAudio: device.canProduce('audio'), canProduceVideo: device.canProduce('video') })
|
||
}
|
||
|
||
/** 返回 Device.rtpCapabilities(用于后端 CreateConsumer) */
|
||
const getRtpCapabilities = () => {
|
||
ensureDeviceLoaded()
|
||
return device.rtpCapabilities
|
||
}
|
||
|
||
/**
|
||
* 通过 WS 请求后端创建 Transport,返回 mediasoup-client 可用的 Transport 元信息
|
||
* @param {'send'|'recv'} direction
|
||
*/
|
||
const requestTransportInfo = async (direction) => {
|
||
const info = await sendWithAck(MEETING_WS_TRANSPORT_CREATE, {
|
||
room_code: roomCode,
|
||
direction
|
||
})
|
||
if (!info || !info.id) {
|
||
throw new Error(`[MediaEngine] 创建 ${direction} Transport 失败:后端返回为空`)
|
||
}
|
||
return info
|
||
}
|
||
|
||
/**
|
||
* 绑定 sendTransport 的 connect + produce 回调(把本地事件桥到 WS 信令)
|
||
* mediasoup-client 约定:
|
||
* - transport.on('connect', ({ dtlsParameters }, callback, errback))
|
||
* 需要在 DTLS 握手完成前告知远端 DTLS 参数
|
||
* - transport.on('produce', ({ kind, rtpParameters, appData }, callback, errback))
|
||
* 需要在 Producer 创建前把 RTP 参数发给远端,拿到远端分配的 producerId 回调 callback({ id })
|
||
*/
|
||
const bindSendTransportEvents = (transport) => {
|
||
transport.on('connect', ({ dtlsParameters }, callback, errback) => {
|
||
sendWithAck(MEETING_WS_TRANSPORT_CONNECT, {
|
||
room_code: roomCode,
|
||
transport_id: transport.id,
|
||
dtls_parameters: dtlsParameters
|
||
}).then(() => callback()).catch((err) => {
|
||
logger('error', '[MediaEngine] sendTransport connect 失败', err)
|
||
errback(err)
|
||
})
|
||
})
|
||
|
||
transport.on('produce', ({ kind, rtpParameters, appData }, callback, errback) => {
|
||
sendWithAck(MEETING_WS_PRODUCE_START, {
|
||
room_code: roomCode,
|
||
transport_id: transport.id,
|
||
kind,
|
||
rtp_parameters: rtpParameters,
|
||
app_data: appData || {}
|
||
}).then((resp) => {
|
||
if (!resp || !resp.producer_id) {
|
||
errback(new Error('后端返回 producer_id 为空'))
|
||
return
|
||
}
|
||
callback({ id: resp.producer_id })
|
||
}).catch((err) => {
|
||
logger('error', '[MediaEngine] sendTransport produce 失败', err)
|
||
errback(err)
|
||
})
|
||
})
|
||
|
||
transport.on('connectionstatechange', (state) => {
|
||
logger('debug', `[MediaEngine] sendTransport state=${state}`)
|
||
})
|
||
}
|
||
|
||
/** recvTransport 只需要桥接 connect 回调(consume 由 store 主动调起) */
|
||
const bindRecvTransportEvents = (transport) => {
|
||
transport.on('connect', ({ dtlsParameters }, callback, errback) => {
|
||
sendWithAck(MEETING_WS_TRANSPORT_CONNECT, {
|
||
room_code: roomCode,
|
||
transport_id: transport.id,
|
||
dtls_parameters: dtlsParameters
|
||
}).then(() => callback()).catch((err) => {
|
||
logger('error', '[MediaEngine] recvTransport connect 失败', err)
|
||
errback(err)
|
||
})
|
||
})
|
||
transport.on('connectionstatechange', (state) => {
|
||
logger('debug', `[MediaEngine] recvTransport state=${state}`)
|
||
})
|
||
}
|
||
|
||
/** in-flight Promise:sendTransport 创建并发锁;避免突发并发下重复创建 */
|
||
let sendTransportPromise = null
|
||
/** in-flight Promise:recvTransport 创建并发锁;Task 15 后入者 burst 订阅时尤其关键 */
|
||
let recvTransportPromise = null
|
||
|
||
/** 按需创建 sendTransport(已存在则复用,并发调用共享同一个 in-flight Promise) */
|
||
const ensureSendTransport = async () => {
|
||
ensureDeviceLoaded()
|
||
if (sendTransport && !sendTransport.closed) {
|
||
return sendTransport
|
||
}
|
||
if (sendTransportPromise) {
|
||
return sendTransportPromise
|
||
}
|
||
// Nit(代码审查 2026-04-23 第 15 条):in-flight 锁必须在 resolve/reject 两种结局下均置空,
|
||
// 否则失败后的下次调用会复用 rejected Promise 直接 throw。这里使用 finally 同时覆盖两路。
|
||
sendTransportPromise = (async () => {
|
||
try {
|
||
return await _createSendTransport()
|
||
} finally {
|
||
sendTransportPromise = null
|
||
}
|
||
})()
|
||
return sendTransportPromise
|
||
}
|
||
|
||
/** 首次创建 sendTransport 的底层流程,原本 inline 在 ensureSendTransport 中 */
|
||
const _createSendTransport = async () => {
|
||
const info = await requestTransportInfo('send')
|
||
// const iceServers = [
|
||
// {
|
||
// urls: 'turn:123.6.102.114:3478?transport=udp',
|
||
// username: 'echochat',
|
||
// credential: 'echochat_2026-1s32dswW@#'
|
||
// }
|
||
//];
|
||
sendTransport = device.createSendTransport({
|
||
id: info.id,
|
||
iceParameters: info.iceParameters,
|
||
iceCandidates: info.iceCandidates,
|
||
dtlsParameters: info.dtlsParameters,
|
||
sctpParameters: info.sctpParameters
|
||
// iceServers
|
||
})
|
||
bindSendTransportEvents(sendTransport)
|
||
logger('info', '[MediaEngine] sendTransport 创建成功', { id: sendTransport.id })
|
||
console.log('[MediaEngine] iceParameters:', sendTransport.iceParameters);
|
||
console.log('[MediaEngine] iceCandidates:', sendTransport.iceCandidates);
|
||
console.log('[MediaEngine] dtlsParameters:', sendTransport.dtlsParameters);
|
||
console.log('[MediaEngine] sctpParameters:', sendTransport.sctpParameters);
|
||
return sendTransport
|
||
}
|
||
|
||
/** 按需创建 recvTransport(已存在则复用,并发调用共享同一个 in-flight Promise) */
|
||
const ensureRecvTransport = async () => {
|
||
ensureDeviceLoaded()
|
||
if (recvTransport && !recvTransport.closed) {
|
||
return recvTransport
|
||
}
|
||
if (recvTransportPromise) {
|
||
return recvTransportPromise
|
||
}
|
||
// Nit(同 ensureSendTransport):finally 覆盖 resolve/reject 两路置空
|
||
recvTransportPromise = (async () => {
|
||
try {
|
||
return await _createRecvTransport()
|
||
} finally {
|
||
recvTransportPromise = null
|
||
}
|
||
})()
|
||
return recvTransportPromise
|
||
}
|
||
|
||
/** 首次创建 recvTransport 的底层流程 */
|
||
const _createRecvTransport = async () => {
|
||
const info = await requestTransportInfo('recv')
|
||
recvTransport = device.createRecvTransport({
|
||
id: info.id,
|
||
iceParameters: info.iceParameters,
|
||
iceCandidates: info.iceCandidates,
|
||
dtlsParameters: info.dtlsParameters,
|
||
sctpParameters: info.sctpParameters
|
||
})
|
||
bindRecvTransportEvents(recvTransport)
|
||
logger('info', '[MediaEngine] recvTransport 创建成功', { id: recvTransport.id })
|
||
console.log('[MediaEngine] iceParameters:', recvTransport.iceParameters);
|
||
console.log('[MediaEngine] iceCandidates:', recvTransport.iceCandidates);
|
||
console.log('[MediaEngine] dtlsParameters:', recvTransport.dtlsParameters);
|
||
console.log('[MediaEngine] sctpParameters:', recvTransport.sctpParameters);
|
||
return recvTransport
|
||
}
|
||
|
||
/**
|
||
* 在 sendTransport 上创建 Producer(推本地音/视频)
|
||
* @param {Object} opts
|
||
* @param {'audio'|'video'} opts.kind
|
||
* @param {MediaStreamTrack} opts.track
|
||
* @param {Object} [opts.encodings]
|
||
* @param {Object} [opts.codecOptions]
|
||
* @param {Object} [opts.appData]
|
||
* @returns {Promise<Producer>}
|
||
*/
|
||
const produce = async ({ kind, track, encodings, codecOptions, appData }) => {
|
||
ensureDeviceLoaded()
|
||
if (!device.canProduce(kind)) {
|
||
throw new Error(`[MediaEngine] Device 不支持 produce kind=${kind}`)
|
||
}
|
||
const transport = await ensureSendTransport()
|
||
const produceOpts = { track }
|
||
if (encodings) produceOpts.encodings = encodings
|
||
if (codecOptions) produceOpts.codecOptions = codecOptions
|
||
produceOpts.appData = { user_id: userId, ...(appData || {}) }
|
||
|
||
const producer = await transport.produce(produceOpts)
|
||
producers.set(producer.id, producer)
|
||
|
||
// 监听 Producer 关闭事件,清理本地索引;外部可见的关闭由 closeProducer 触发 WS
|
||
producer.on('transportclose', () => {
|
||
producers.delete(producer.id)
|
||
logger('debug', `[MediaEngine] producer ${producer.id} transportclose`)
|
||
})
|
||
producer.on('trackended', () => {
|
||
logger('warn', `[MediaEngine] producer ${producer.id} trackended,将触发关闭`)
|
||
closeProducer(producer.id).catch((err) => logger('error', '关闭 Producer 失败', err))
|
||
})
|
||
|
||
logger('info', `[MediaEngine] producer 创建成功 kind=${kind} id=${producer.id}`)
|
||
return producer
|
||
}
|
||
|
||
const tuneProducerEncoding = async (producerId, encodingPatch = {}) => {
|
||
const producer = producers.get(producerId)
|
||
const sender = producer && producer.rtpSender
|
||
if (!sender || typeof sender.getParameters !== 'function' || typeof sender.setParameters !== 'function') {
|
||
return false
|
||
}
|
||
try {
|
||
const params = sender.getParameters() || {}
|
||
params.encodings = Array.isArray(params.encodings) && params.encodings.length
|
||
? params.encodings
|
||
: [{}]
|
||
params.encodings = params.encodings.map((encoding) => ({ ...encoding, ...encodingPatch }))
|
||
await sender.setParameters(params)
|
||
logger('info', '[MediaEngine] producer 编码参数已更新', { producerId, encodingPatch })
|
||
return true
|
||
} catch (e) {
|
||
logger('warn', '[MediaEngine] producer 编码参数更新失败', e)
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 订阅远端 Producer:请求后端创建 Consumer → 本地 consume → 等 track 挂好后 resume
|
||
*
|
||
* 与 Task 9 决策 Q6=B 对齐的规范流程:
|
||
* 1. WS consume.start → 拿到 { id, producerId, kind, rtpParameters } (Node 侧 paused)
|
||
* 2. recvTransport.consume(...) 得到本地 Consumer(track 可用)
|
||
* 3. 调用方把 track 挂到 <video>/<audio> 元素(DOM 就绪)
|
||
* 4. 调用 consumer.resume()(此方法返回的 consumer 暴露 resume())
|
||
*
|
||
* 本函数执行完 1+2 后返回 consumer,调用方挂 track 后必须调一次 engine.resumeConsumer(consumerId)
|
||
* 以告知后端 → Node 把 Consumer 从 paused 切到 active
|
||
*
|
||
* @param {Object} opts
|
||
* @param {string} opts.producerId - 要订阅的远端 Producer ID
|
||
* @returns {Promise<Consumer>} 本地 Consumer 实例(此时仍 paused)
|
||
*/
|
||
const consume = async ({ producerId }) => {
|
||
ensureDeviceLoaded()
|
||
if (!producerId) throw new Error('[MediaEngine] producerId 不能为空')
|
||
|
||
const transport = await ensureRecvTransport()
|
||
const info = await sendWithAck(MEETING_WS_CONSUME_START, {
|
||
room_code: roomCode,
|
||
transport_id: transport.id,
|
||
producer_id: producerId,
|
||
rtp_capabilities: device.rtpCapabilities
|
||
})
|
||
if (!info || !info.id) {
|
||
throw new Error('[MediaEngine] 后端未返回 Consumer 元信息')
|
||
}
|
||
|
||
const consumer = await transport.consume({
|
||
id: info.id,
|
||
producerId: info.producerId || info.producer_id || producerId,
|
||
kind: info.kind,
|
||
rtpParameters: info.rtpParameters
|
||
})
|
||
consumers.set(consumer.id, consumer)
|
||
|
||
consumer.on('transportclose', () => {
|
||
consumers.delete(consumer.id)
|
||
logger('debug', `[MediaEngine] consumer ${consumer.id} transportclose`)
|
||
})
|
||
|
||
logger('info', `[MediaEngine] consumer 创建成功 id=${consumer.id} kind=${consumer.kind}`)
|
||
return consumer
|
||
}
|
||
|
||
/**
|
||
* 通知后端 resume Consumer(track 已挂载到 DOM 后调用)
|
||
* 对应 Task 9 决策 Q6=B 的 meeting.consume.resume WS 事件
|
||
* @param {string} consumerId
|
||
*/
|
||
const resumeConsumer = async (consumerId) => {
|
||
ensureNotClosed()
|
||
await sendWithAck(MEETING_WS_CONSUME_RESUME, {
|
||
room_code: roomCode,
|
||
consumer_id: consumerId
|
||
})
|
||
const local = consumers.get(consumerId)
|
||
if (local && typeof local.resume === 'function') {
|
||
await local.resume()
|
||
}
|
||
logger('info', `[MediaEngine] consumer ${consumerId} resumed`)
|
||
}
|
||
|
||
/**
|
||
* 关闭指定 Producer(本地 close + WS 通知后端)
|
||
* 幂等:producerId 不存在时静默返回
|
||
*/
|
||
const closeProducer = async (producerId) => {
|
||
if (closed) return
|
||
const producer = producers.get(producerId)
|
||
if (!producer) return
|
||
try {
|
||
if (!producer.closed) producer.close()
|
||
} catch (e) {
|
||
logger('warn', '[MediaEngine] producer.close 抛错', e)
|
||
}
|
||
producers.delete(producerId)
|
||
try {
|
||
await sendWithAck(MEETING_WS_PRODUCER_CLOSE, {
|
||
room_code: roomCode,
|
||
producer_id: producerId
|
||
})
|
||
} catch (e) {
|
||
// 后端已清理也视为成功(幂等);只记日志
|
||
logger('warn', `[MediaEngine] producer.close WS 通知失败 ${producerId}`, e)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 暂停指定 Producer:mediasoup-client 内部会把 track.enabled=false,
|
||
* 远端 Consumer 收到的就是静音/黑帧,但本地 track 仍持有硬件不释放。
|
||
* 用于"关摄像头/麦克风"按钮:避免在 iOS Safari / 微信 WebView 上释放硬件后再申请被拒(NotAllowedError)。
|
||
*/
|
||
const pauseProducer = (producerId) => {
|
||
const producer = producers.get(producerId)
|
||
if (!producer || producer.closed) return false
|
||
try {
|
||
if (!producer.paused) producer.pause()
|
||
// 双保险:有些 mediasoup-client 版本不会自动改 track.enabled
|
||
if (producer.track) producer.track.enabled = false
|
||
return true
|
||
} catch (e) {
|
||
logger('warn', '[MediaEngine] producer.pause 抛错', e)
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** 恢复 Producer:与 pauseProducer 对应 */
|
||
const resumeProducer = (producerId) => {
|
||
const producer = producers.get(producerId)
|
||
if (!producer || producer.closed) return false
|
||
try {
|
||
if (producer.paused) producer.resume()
|
||
if (producer.track) producer.track.enabled = true
|
||
return true
|
||
} catch (e) {
|
||
logger('warn', '[MediaEngine] producer.resume 抛错', e)
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** 本地 Consumer 关闭(通常由 producer.new closed=true 广播触发,无需额外 WS) */
|
||
const closeConsumer = (consumerId) => {
|
||
const consumer = consumers.get(consumerId)
|
||
if (!consumer) return
|
||
try {
|
||
if (!consumer.closed) consumer.close()
|
||
} catch (e) {
|
||
logger('warn', '[MediaEngine] consumer.close 抛错', e)
|
||
}
|
||
consumers.delete(consumerId)
|
||
}
|
||
|
||
/** 释放所有资源(本地 close,不触发 WS;由 store 在离会时调用) */
|
||
const close = () => {
|
||
if (closed) return
|
||
closed = true
|
||
try {
|
||
producers.forEach((p) => { try { if (!p.closed) p.close() } catch {} })
|
||
producers.clear()
|
||
consumers.forEach((c) => { try { if (!c.closed) c.close() } catch {} })
|
||
consumers.clear()
|
||
if (sendTransport && !sendTransport.closed) sendTransport.close()
|
||
if (recvTransport && !recvTransport.closed) recvTransport.close()
|
||
} finally {
|
||
sendTransport = null
|
||
recvTransport = null
|
||
device = null
|
||
}
|
||
logger('info', '[MediaEngine] 已关闭')
|
||
}
|
||
|
||
return {
|
||
loadDevice,
|
||
getRtpCapabilities,
|
||
ensureSendTransport,
|
||
ensureRecvTransport,
|
||
produce,
|
||
consume,
|
||
resumeConsumer,
|
||
closeProducer,
|
||
pauseProducer,
|
||
resumeProducer,
|
||
tuneProducerEncoding,
|
||
closeConsumer,
|
||
close,
|
||
getDevice: () => device,
|
||
getSendTransport: () => sendTransport,
|
||
getRecvTransport: () => recvTransport,
|
||
getProducer: (id) => producers.get(id),
|
||
getConsumer: (id) => consumers.get(id)
|
||
}
|
||
// #endif
|
||
}
|
||
|
||
function defaultLogger(level, ...args) {
|
||
const fn = console[level] || console.log
|
||
fn.call(console, ...args)
|
||
}
|