Files
EchoChat/frontend/src/utils/mediasoup-client.js
2026-05-27 14:38:55 +08:00

520 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 PromisesendTransport 创建并发锁;避免突发并发下重复创建 */
let sendTransportPromise = null
/** in-flight PromiserecvTransport 创建并发锁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同 ensureSendTransportfinally 覆盖 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(...) 得到本地 Consumertrack 可用)
* 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 Consumertrack 已挂载到 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)
}
}
/**
* 暂停指定 Producermediasoup-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)
}