feat(phase2e-2): 前端 mediasoup-client 集成 + Pinia meeting Store(Task 9)

- frontend/src/api/meeting.js:12 个 REST 接口封装,统一 unwrap envelope.data
- frontend/src/services/websocket.js:新增 sendWithAck(Promise 化 + 超时 + 序列号)
- frontend/src/utils/mediasoup-client.js:MediaEngine 包装 Device/Transport/Producer/Consumer
- frontend/src/store/meeting.js:Pinia 会议状态机,桥接 14 个 WS 事件 + cleanupStaleMeetings
- frontend/src/constants/meeting.js:状态枚举 + 事件名集中管理
- frontend/src/pages/meeting/debug.vue:临时调试页(H5 原生 video/audio DOM 绕过 uni 组件限制)
- backend:meeting.consume.resume WS 事件 + create/join 响应透传 router_id + rtp_capabilities
- 文档:frontend/meeting.md、websocket.md、CURRENT_STATUS、plan 全部同步 Task 9 落地

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-22 11:22:49 +08:00
parent e7f6a32dfe
commit f97fec24a8
22 changed files with 2314 additions and 60 deletions

View File

@@ -0,0 +1,408 @@
/**
* 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}`)
})
}
/** 按需创建 sendTransport已存在则复用 */
const ensureSendTransport = async () => {
ensureDeviceLoaded()
if (sendTransport && !sendTransport.closed) {
return sendTransport
}
const info = await requestTransportInfo('send')
sendTransport = device.createSendTransport({
id: info.id,
iceParameters: info.iceParameters,
iceCandidates: info.iceCandidates,
dtlsParameters: info.dtlsParameters,
sctpParameters: info.sctpParameters
})
bindSendTransportEvents(sendTransport)
logger('info', '[MediaEngine] sendTransport 创建成功', { id: sendTransport.id })
return sendTransport
}
/** 按需创建 recvTransport已存在则复用 */
const ensureRecvTransport = async () => {
ensureDeviceLoaded()
if (recvTransport && !recvTransport.closed) {
return recvTransport
}
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 })
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
}
/**
* 订阅远端 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)
}
}
/** 本地 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,
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)
}