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:
194
frontend/package-lock.json
generated
194
frontend/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-4080720251210001",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4080720251210001",
|
||||
"@dcloudio/uni-ui": "^1.5.11",
|
||||
"mediasoup-client": "^3.19.0",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^3.2.3",
|
||||
"vue": "^3.4.21",
|
||||
@@ -3662,6 +3663,27 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lukeed/csprng": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
|
||||
"integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@lukeed/uuid": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz",
|
||||
"integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lukeed/csprng": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -4393,12 +4415,34 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||
"integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/events-alias": {
|
||||
"name": "@types/events",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
|
||||
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-legacy": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-legacy/-/plugin-legacy-5.3.2.tgz",
|
||||
@@ -4937,6 +4981,39 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/awaitqueue": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/awaitqueue/-/awaitqueue-3.3.0.tgz",
|
||||
"integrity": "sha512-zLxDhzQbzHmOyvxi7g3OlfR7jLrcmElStPxfLPpJkrFSDm71RSrY/MvsDA8Btlx8X1nOHUzGhQvc6bdUjL2f2w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mediasoup"
|
||||
}
|
||||
},
|
||||
"node_modules/awaitqueue/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||
"version": "0.4.15",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz",
|
||||
@@ -5756,6 +5833,16 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/events-alias": {
|
||||
"name": "events",
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
@@ -5851,6 +5938,18 @@
|
||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fake-mediastreamtrack": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fake-mediastreamtrack/-/fake-mediastreamtrack-2.2.1.tgz",
|
||||
"integrity": "sha512-SITLc7UTDirSdgLGORrlmqjWLJtbtfIz/xO7DwVbJH3f0ds+NQok4ccl/WEzz49NSgiUlXf2wDW0+y5C+TO6EA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@lukeed/uuid": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -6144,6 +6243,39 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/h264-profile-level-id": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/h264-profile-level-id/-/h264-profile-level-id-2.3.2.tgz",
|
||||
"integrity": "sha512-hnq1UDlw7WGJV6GCr/g7wnkHYUjdAY2bis9rgn2JqSdQS2WfVvnt1ZE9g8nTguracodf5LLKZOwURsDN49YtBQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mediasoup"
|
||||
}
|
||||
},
|
||||
"node_modules/h264-profile-level-id/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
@@ -6647,6 +6779,59 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mediasoup-client": {
|
||||
"version": "3.19.0",
|
||||
"resolved": "https://registry.npmjs.org/mediasoup-client/-/mediasoup-client-3.19.0.tgz",
|
||||
"integrity": "sha512-XU4Y7yOe0FqUXwu/Mf8Wgrp5AOde1l60FEFNvSSrR2nNzbw3+jAcHQV35Ii9bXb21PT5XaW4SkI4dhxhwFXR3Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@types/debug": "^4.1.13",
|
||||
"@types/events-alias": "npm:@types/events@^3.0.3",
|
||||
"awaitqueue": "^3.3.0",
|
||||
"debug": "^4.4.3",
|
||||
"events-alias": "npm:events@^3.3.0",
|
||||
"fake-mediastreamtrack": "^2.2.1",
|
||||
"h264-profile-level-id": "^2.3.2",
|
||||
"sdp-transform": "^3.0.0",
|
||||
"supports-color": "^10.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mediasoup"
|
||||
}
|
||||
},
|
||||
"node_modules/mediasoup-client/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/mediasoup-client/node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/meow": {
|
||||
"version": "13.2.0",
|
||||
"resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz",
|
||||
@@ -7736,6 +7921,15 @@
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
|
||||
"integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-4080720251210001",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4080720251210001",
|
||||
"@dcloudio/uni-ui": "^1.5.11",
|
||||
"mediasoup-client": "^3.19.0",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^3.2.3",
|
||||
"vue": "^3.4.21",
|
||||
|
||||
160
frontend/src/api/meeting.js
Normal file
160
frontend/src/api/meeting.js
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 会议模块 REST API
|
||||
*
|
||||
* 对应后端路由:/api/v1/meeting/*(见 backend/go-service/app/meeting/router.go)
|
||||
*
|
||||
* 设计原则:
|
||||
* - 本文件仅做 REST 封装,业务层(store/meeting.js)统一调本文件方法
|
||||
* - WebRTC 媒体层信令通过 WS(services/websocket.js + sendWithAck)发送,不在此处
|
||||
* - 响应中的 DTO 字段命名与后端 DTO 完全一致(下划线),前端按需再转换
|
||||
*/
|
||||
|
||||
import { get, post } from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 统一拆包:utils/request.js 返回完整 envelope { code, message, data, trace_id, time },
|
||||
* 业务层只关心 data 负载;这里一次性拆包,避免每个 store/页面都写 .data.xxx
|
||||
* 对无 body 的响应(如 leave/end)返回 null 而非 undefined,方便调用方判空
|
||||
*/
|
||||
const unwrap = (promise) => promise.then((resp) => (resp && 'data' in resp ? (resp.data ?? null) : (resp ?? null)))
|
||||
|
||||
// ==================== 会议房间(6 接口) ====================
|
||||
|
||||
/**
|
||||
* 创建即时会议
|
||||
* @param {Object} payload - { title, password?, max_members? }
|
||||
* @returns {Promise<{ room: MeetingRoomDTO }>}
|
||||
*/
|
||||
const createRoom = (payload) => {
|
||||
return unwrap(post('/api/v1/meeting/rooms', payload))
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询会议详情(含参与者列表)
|
||||
* @param {string} roomCode - 会议号(XXX-XXX-XXX 或 9 位纯数字)
|
||||
* @returns {Promise<{ room, participants, online_count }>}
|
||||
*/
|
||||
const getRoom = (roomCode) => {
|
||||
return unwrap(get(`/api/v1/meeting/rooms/${roomCode}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入会议
|
||||
* @param {string} roomCode - 会议号
|
||||
* @param {Object} [payload] - { password? }
|
||||
* @returns {Promise<{ room, participant, router_id }>}
|
||||
*/
|
||||
const joinRoom = (roomCode, payload = {}) => {
|
||||
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/join`, payload))
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开会议
|
||||
* @param {string} roomCode
|
||||
* @returns {Promise<{ duration }>}
|
||||
*/
|
||||
const leaveRoom = (roomCode) => {
|
||||
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/leave`))
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束会议(仅主持人可调)
|
||||
* @param {string} roomCode
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const endRoom = (roomCode) => {
|
||||
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/end`))
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的会议列表(历史 + 进行中)
|
||||
* @param {Object} [params] - { status?, before_id?, limit? }
|
||||
* @returns {Promise<{ list, has_more }>}
|
||||
*/
|
||||
const listMyMeetings = (params = {}) => {
|
||||
return unwrap(get('/api/v1/meeting/rooms/mine', params))
|
||||
}
|
||||
|
||||
// ==================== 会议成员管理(3 接口) ====================
|
||||
|
||||
/**
|
||||
* 转让主持人
|
||||
* @param {string} roomCode
|
||||
* @param {number} targetUserID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const transferHost = (roomCode, targetUserID) => {
|
||||
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/transfer-host`, {
|
||||
target_user_id: targetUserID
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除成员(踢人,仅主持人)
|
||||
* @param {string} roomCode
|
||||
* @param {number} userID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const kickMember = (roomCode, userID) => {
|
||||
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/kick`, { user_id: userID }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 邀请用户(批量)
|
||||
* @param {string} roomCode
|
||||
* @param {number[]} inviteeIDs - 被邀请用户 ID 数组(1~50)
|
||||
* @returns {Promise<{ pushed, skipped }>}
|
||||
*/
|
||||
const inviteUsers = (roomCode, inviteeIDs) => {
|
||||
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/invite`, {
|
||||
invitee_ids: inviteeIDs
|
||||
}))
|
||||
}
|
||||
|
||||
// ==================== 邀请兑换(1 接口) ====================
|
||||
|
||||
/**
|
||||
* 兑换邀请 Token(从通知点进链接时使用)
|
||||
* @param {string} token - 邀请链接中的 token 字符串
|
||||
* @returns {Promise<{ room_code, inviter_id, has_password }>}
|
||||
*/
|
||||
const redeemInvite = (token) => {
|
||||
return unwrap(post(`/api/v1/meeting/invite-tokens/${token}/redeem`))
|
||||
}
|
||||
|
||||
// ==================== 会议内聊天(2 接口) ====================
|
||||
|
||||
/**
|
||||
* 发送会议内聊天消息
|
||||
* @param {string} roomCode
|
||||
* @param {string} content - 1~500 字
|
||||
* @returns {Promise<{ message }>}
|
||||
*/
|
||||
const sendChat = (roomCode, content) => {
|
||||
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/chats`, { content }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询会议聊天历史(游标分页)
|
||||
* @param {string} roomCode
|
||||
* @param {Object} [params] - { before_id?, limit? }
|
||||
* @returns {Promise<{ list, has_more }>}
|
||||
*/
|
||||
const listChats = (roomCode, params = {}) => {
|
||||
return unwrap(get(`/api/v1/meeting/rooms/${roomCode}/chats`, params))
|
||||
}
|
||||
|
||||
export default {
|
||||
createRoom,
|
||||
getRoom,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
endRoom,
|
||||
listMyMeetings,
|
||||
transferHost,
|
||||
kickMember,
|
||||
inviteUsers,
|
||||
redeemInvite,
|
||||
sendChat,
|
||||
listChats
|
||||
}
|
||||
163
frontend/src/constants/meeting.js
Normal file
163
frontend/src/constants/meeting.js
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 会议模块前端常量
|
||||
*
|
||||
* 与后端 backend/go-service/app/constants/meeting.go 严格一致
|
||||
* 新增/修改任一常量时必须同步维护两端,否则运行时会因事件名不匹配而静默失败
|
||||
*
|
||||
* 版本说明:
|
||||
* - Task 6(2026-04-20)落地 13 个 WS 事件
|
||||
* - Task 9(2026-04-22)新增 meeting.consume.resume,总计 14 个事件
|
||||
*/
|
||||
|
||||
// ==================== 会议类型 ====================
|
||||
|
||||
export const MEETING_TYPE_INSTANT = 1 // 即时会议(MVP 仅此)
|
||||
export const MEETING_TYPE_SCHEDULED = 2 // 预约会议(Phase 2e-3)
|
||||
|
||||
export const MEETING_TYPE_LABEL = {
|
||||
[MEETING_TYPE_INSTANT]: '即时会议',
|
||||
[MEETING_TYPE_SCHEDULED]: '预约会议'
|
||||
}
|
||||
|
||||
// ==================== 会议状态 ====================
|
||||
|
||||
export const MEETING_STATUS_PENDING = 0 // 未开始(仅预约会议)
|
||||
export const MEETING_STATUS_ACTIVE = 1 // 进行中
|
||||
export const MEETING_STATUS_ENDED = 2 // 已结束
|
||||
|
||||
export const MEETING_STATUS_LABEL = {
|
||||
[MEETING_STATUS_PENDING]: '未开始',
|
||||
[MEETING_STATUS_ACTIVE]: '进行中',
|
||||
[MEETING_STATUS_ENDED]: '已结束'
|
||||
}
|
||||
|
||||
// ==================== 参与者角色 ====================
|
||||
|
||||
export const MEETING_ROLE_PARTICIPANT = 0 // 普通参会人
|
||||
export const MEETING_ROLE_HOST = 1 // 主持人
|
||||
export const MEETING_ROLE_COHOST = 2 // 联合主持人(保留,MVP 不用)
|
||||
|
||||
export const MEETING_ROLE_LABEL = {
|
||||
[MEETING_ROLE_PARTICIPANT]: '参会人',
|
||||
[MEETING_ROLE_HOST]: '主持人',
|
||||
[MEETING_ROLE_COHOST]: '联合主持人'
|
||||
}
|
||||
|
||||
// ==================== 会议结束原因 ====================
|
||||
|
||||
export const MEETING_ENDED_REASON_HOST_ENDED = 'host_ended'
|
||||
export const MEETING_ENDED_REASON_EMPTY_TTL = 'empty_ttl'
|
||||
export const MEETING_ENDED_REASON_ADMIN_FORCE = 'admin_force'
|
||||
export const MEETING_ENDED_REASON_SYSTEM_ERROR = 'system_error'
|
||||
|
||||
export const MEETING_ENDED_REASON_LABEL = {
|
||||
[MEETING_ENDED_REASON_HOST_ENDED]: '主持人结束',
|
||||
[MEETING_ENDED_REASON_EMPTY_TTL]: '空房超时',
|
||||
[MEETING_ENDED_REASON_ADMIN_FORCE]: '管理员强制结束',
|
||||
[MEETING_ENDED_REASON_SYSTEM_ERROR]: '系统异常'
|
||||
}
|
||||
|
||||
// ==================== 离会原因 ====================
|
||||
|
||||
export const MEETING_LEFT_REASON_SELF = 'self'
|
||||
export const MEETING_LEFT_REASON_KICKED = 'kicked'
|
||||
export const MEETING_LEFT_REASON_HOST_END = 'host_end'
|
||||
export const MEETING_LEFT_REASON_EMPTY_TTL = 'empty_ttl'
|
||||
export const MEETING_LEFT_REASON_DISCONNECT = 'disconnect'
|
||||
|
||||
export const MEETING_LEFT_REASON_LABEL = {
|
||||
[MEETING_LEFT_REASON_SELF]: '主动离开',
|
||||
[MEETING_LEFT_REASON_KICKED]: '被移除',
|
||||
[MEETING_LEFT_REASON_HOST_END]: '会议结束',
|
||||
[MEETING_LEFT_REASON_EMPTY_TTL]: '空房超时',
|
||||
[MEETING_LEFT_REASON_DISCONNECT]: '网络断开'
|
||||
}
|
||||
|
||||
// ==================== 主持人变更自动转让原因(Task 8 引入) ====================
|
||||
|
||||
export const MEETING_HOST_AUTO_REASON_GRACE_EXPIRED = 'host_grace_expired' // 主持人宽限期到期自动转让
|
||||
export const MEETING_HOST_AUTO_REASON_HOST_KICKED = 'host_kicked' // (保留)主持人被管理员踢出自动转让
|
||||
|
||||
export const MEETING_HOST_AUTO_REASON_LABEL = {
|
||||
[MEETING_HOST_AUTO_REASON_GRACE_EXPIRED]: '主持人掉线超时自动转让',
|
||||
[MEETING_HOST_AUTO_REASON_HOST_KICKED]: '主持人被移除自动转让'
|
||||
}
|
||||
|
||||
// ==================== 默认配置常量 ====================
|
||||
|
||||
export const MEETING_MVP_MAX_MEMBERS = 8 // MVP 单会议硬上限
|
||||
export const MEETING_HOST_GRACE_SECONDS = 120 // 主持人掉线宽限期(Task 8)
|
||||
export const MEETING_EMPTY_ROOM_TTL_SECONDS = 300 // 空房销毁 TTL(Task 8)
|
||||
export const MEETING_WS_ACK_TIMEOUT_MS = 10000 // WS 事件 ACK 等待超时(Task 9)
|
||||
|
||||
// ==================== WS 事件名(14 个,与后端 constants/meeting.go 一一对应) ====================
|
||||
|
||||
// 房间组(3 个)
|
||||
export const MEETING_WS_ROOM_JOIN = 'meeting.room.join' // C→S
|
||||
export const MEETING_WS_ROOM_LEAVE = 'meeting.room.leave' // C→S
|
||||
export const MEETING_WS_ROOM_ENDED = 'meeting.room.ended' // S→C
|
||||
|
||||
// 成员组(6 个)
|
||||
export const MEETING_WS_MEMBER_JOINED = 'meeting.member.joined' // S→C
|
||||
export const MEETING_WS_MEMBER_LEFT = 'meeting.member.left' // S→C
|
||||
export const MEETING_WS_MEMBER_STATE_CHANGED = 'meeting.member.state.changed' // 双向
|
||||
export const MEETING_WS_MEMBER_KICKED = 'meeting.member.kicked' // S→C(定向)
|
||||
export const MEETING_WS_MEMBER_PRODUCER_NEW = 'meeting.member.producer.new' // S→C
|
||||
export const MEETING_WS_HOST_CHANGED = 'meeting.host.changed' // S→C
|
||||
|
||||
// 媒体组(6 个,含 Task 9 新增 consume.resume)
|
||||
export const MEETING_WS_TRANSPORT_CREATE = 'meeting.transport.create' // C→S
|
||||
export const MEETING_WS_TRANSPORT_CONNECT = 'meeting.transport.connect' // C→S
|
||||
export const MEETING_WS_PRODUCE_START = 'meeting.produce.start' // C→S
|
||||
export const MEETING_WS_CONSUME_START = 'meeting.consume.start' // C→S
|
||||
export const MEETING_WS_CONSUME_RESUME = 'meeting.consume.resume' // C→S(Task 9 新增)
|
||||
export const MEETING_WS_PRODUCER_CLOSE = 'meeting.producer.close' // C→S
|
||||
|
||||
// 聊天(1 个)
|
||||
export const MEETING_WS_CHAT_MESSAGE = 'meeting.chat.message' // S→C
|
||||
|
||||
/** C→S 客户端可主动发起的事件白名单 */
|
||||
export const MEETING_WS_CLIENT_EVENTS = [
|
||||
MEETING_WS_ROOM_JOIN,
|
||||
MEETING_WS_ROOM_LEAVE,
|
||||
MEETING_WS_MEMBER_STATE_CHANGED,
|
||||
MEETING_WS_TRANSPORT_CREATE,
|
||||
MEETING_WS_TRANSPORT_CONNECT,
|
||||
MEETING_WS_PRODUCE_START,
|
||||
MEETING_WS_CONSUME_START,
|
||||
MEETING_WS_CONSUME_RESUME,
|
||||
MEETING_WS_PRODUCER_CLOSE
|
||||
]
|
||||
|
||||
/** S→C 服务端广播事件白名单(用于 Store 注册监听时统一处理) */
|
||||
export const MEETING_WS_SERVER_EVENTS = [
|
||||
MEETING_WS_ROOM_ENDED,
|
||||
MEETING_WS_MEMBER_JOINED,
|
||||
MEETING_WS_MEMBER_LEFT,
|
||||
MEETING_WS_MEMBER_STATE_CHANGED,
|
||||
MEETING_WS_MEMBER_KICKED,
|
||||
MEETING_WS_MEMBER_PRODUCER_NEW,
|
||||
MEETING_WS_HOST_CHANGED,
|
||||
MEETING_WS_CHAT_MESSAGE
|
||||
]
|
||||
|
||||
// ==================== 本地会议状态机(前端专用,与后端 meeting_rooms.status 无关) ====================
|
||||
|
||||
// Task 9 用于 store/meeting.js 驱动 UI 的本地状态
|
||||
export const MEETING_LOCAL_STATE_IDLE = 'idle' // 未进入会议
|
||||
export const MEETING_LOCAL_STATE_JOINING = 'joining' // REST JoinRoom 中
|
||||
export const MEETING_LOCAL_STATE_CONNECTING = 'connecting' // Device load + Transport 创建中
|
||||
export const MEETING_LOCAL_STATE_CONNECTED = 'connected' // 媒体层就绪,可推流/收流
|
||||
export const MEETING_LOCAL_STATE_RECONNECTING = 'reconnecting' // WS 断线重连期间(Task 8 host 宽限期共用)
|
||||
export const MEETING_LOCAL_STATE_LEAVING = 'leaving' // REST LeaveRoom 中
|
||||
export const MEETING_LOCAL_STATE_ENDED = 'ended' // 会议已结束(room.ended 收到后进入)
|
||||
|
||||
export const MEETING_LOCAL_STATE_LABEL = {
|
||||
[MEETING_LOCAL_STATE_IDLE]: '未入会',
|
||||
[MEETING_LOCAL_STATE_JOINING]: '加入中',
|
||||
[MEETING_LOCAL_STATE_CONNECTING]: '连接中',
|
||||
[MEETING_LOCAL_STATE_CONNECTED]: '已连接',
|
||||
[MEETING_LOCAL_STATE_RECONNECTING]: '重连中',
|
||||
[MEETING_LOCAL_STATE_LEAVING]: '离开中',
|
||||
[MEETING_LOCAL_STATE_ENDED]: '已结束'
|
||||
}
|
||||
@@ -142,6 +142,12 @@
|
||||
"navigationBarTitleText": "会议"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/debug",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会议调试(Task 9)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/notify/index",
|
||||
"style": {
|
||||
|
||||
340
frontend/src/pages/meeting/debug.vue
Normal file
340
frontend/src/pages/meeting/debug.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<!--
|
||||
会议调试页(Task 9 临时产物)
|
||||
|
||||
目的:
|
||||
- 在没有正式 UI 的情况下,手工验证 frontend/src/store/meeting.js 各 action 通链路
|
||||
- 仅 H5 端可用(mediasoup-client 平台限制,见 Task 9 决策 Q1)
|
||||
|
||||
注意:
|
||||
- uni-app H5 的 <video>/<audio> 标签会被编译成 uni 自定义组件(不支持 srcObject)
|
||||
- 因此本页用 <view> 作为容器,在 H5 环境用原生 document.createElement('video'/'audio') 挂 WebRTC track
|
||||
|
||||
Task 10 开始后此页可删除或重构为正式 UI,路由信息仅在 pages.json 的 debug 块中保留
|
||||
-->
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="title">会议调试(Task 9)</text>
|
||||
<text class="state">状态:{{ localStateLabel }}</text>
|
||||
<text class="state">房号:{{ currentRoom?.room_code || '-' }} ({{ currentRoom?.title || '-' }})</text>
|
||||
<text class="state">是否主持人:{{ isHost ? '是' : '否' }}</text>
|
||||
<text class="state" v-if="lastEndedReason">结束原因:{{ lastEndedReason }}</text>
|
||||
<text class="state" v-if="lastAutoHostReason">自动转让:{{ lastAutoHostReason }}</text>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<text class="subtitle">1. 会议生命周期</text>
|
||||
<view class="row">
|
||||
<input v-model="form.title" class="input" placeholder="会议标题" />
|
||||
<button class="btn primary" :disabled="isInMeeting" @click="onCreate">创建并入会</button>
|
||||
</view>
|
||||
<view class="row">
|
||||
<input v-model="form.roomCode" class="input" placeholder="会议号(XXX-XXX-XXX 或 9 位)" />
|
||||
<input v-model="form.password" class="input password" placeholder="密码(可选)" />
|
||||
<button class="btn primary" :disabled="isInMeeting" @click="onJoin">加入会议</button>
|
||||
</view>
|
||||
<view class="row">
|
||||
<button class="btn" :disabled="!isInMeeting" @click="onLeave">离开会议</button>
|
||||
<button class="btn danger" :disabled="!isHost || !isInMeeting" @click="onEnd">结束会议(host)</button>
|
||||
<button class="btn" @click="onCleanupStale">清理遗留会议</button>
|
||||
</view>
|
||||
<text class="hint" v-if="lastHint">{{ lastHint }}</text>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<text class="subtitle">2. 本地推流</text>
|
||||
<view class="row">
|
||||
<button class="btn" :disabled="!isConnected" @click="onToggleAudio">
|
||||
{{ localAudioEnabled ? '关闭麦克风' : '开启麦克风' }}
|
||||
</button>
|
||||
<button class="btn" :disabled="!isConnected" @click="onToggleVideo">
|
||||
{{ localVideoEnabled ? '关闭摄像头' : '开启摄像头' }}
|
||||
</button>
|
||||
</view>
|
||||
<view class="video-wrap">
|
||||
<text class="caption">本地预览</text>
|
||||
<view ref="localVideoBox" class="video-slot"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<text class="subtitle">3. 远端参与者({{ activeParticipants.length }} 人活跃)</text>
|
||||
<view v-for="p in activeParticipants" :key="p.user_id" class="participant">
|
||||
<text class="pname">userId={{ p.user_id }} role={{ p.role }} joined={{ p.joined_at }}</text>
|
||||
<view class="video-wrap">
|
||||
<view :ref="el => setRemoteBox(p.user_id, el)" class="video-slot small"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<text class="subtitle">4. 会议内聊天</text>
|
||||
<view class="chat-box">
|
||||
<view v-for="(m, i) in chatMessages" :key="i" class="chat-msg">
|
||||
<text>{{ m.user_name || m.user_id }}:{{ m.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="row">
|
||||
<input v-model="chatInput" class="input" placeholder="输入聊天内容" />
|
||||
<button class="btn" :disabled="!isInMeeting" @click="onSendChat">发送</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useMeetingStore } from '@/store/meeting'
|
||||
import { MEETING_LOCAL_STATE_LABEL } from '@/constants/meeting'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const meetingStore = useMeetingStore()
|
||||
const {
|
||||
localState,
|
||||
currentRoom,
|
||||
chatMessages,
|
||||
lastEndedReason,
|
||||
lastAutoHostReason,
|
||||
localAudioEnabled,
|
||||
localVideoEnabled,
|
||||
isInMeeting,
|
||||
isConnected,
|
||||
isHost,
|
||||
activeParticipants,
|
||||
remoteConsumers
|
||||
} = storeToRefs(meetingStore)
|
||||
|
||||
const form = reactive({ title: '调试会议', roomCode: '', password: '' })
|
||||
const chatInput = ref('')
|
||||
const lastHint = ref('')
|
||||
|
||||
// 容器 ref(保存 Vue 组件实例或真实 DOM)
|
||||
const localVideoBox = ref(null)
|
||||
const remoteBoxes = reactive({})
|
||||
|
||||
const setRemoteBox = (userId, el) => {
|
||||
if (el) remoteBoxes[userId] = el
|
||||
else delete remoteBoxes[userId]
|
||||
}
|
||||
|
||||
const localStateLabel = computed(() => {
|
||||
const label = MEETING_LOCAL_STATE_LABEL[localState.value] || localState.value
|
||||
return `${localState.value} (${label})`
|
||||
})
|
||||
|
||||
// ==================== 原生 DOM 辅助(仅 H5)====================
|
||||
|
||||
/** 把 uni <view> 的 ref 统一转成真实 DOM 元素 */
|
||||
const resolveDom = (refValue) => {
|
||||
if (!refValue) return null
|
||||
// #ifdef H5
|
||||
if (refValue instanceof HTMLElement) return refValue
|
||||
if (refValue.$el) return refValue.$el
|
||||
if (refValue.exposed && refValue.exposed.$el) return refValue.exposed.$el
|
||||
// #endif
|
||||
return null
|
||||
}
|
||||
|
||||
/** 在容器内确保存在一个 <video> / <audio> 原生元素,并返回它 */
|
||||
const ensureMediaEl = (container, tagName, className = '') => {
|
||||
// #ifdef H5
|
||||
const parent = resolveDom(container)
|
||||
if (!parent) return null
|
||||
let el = parent.querySelector(`:scope > ${tagName}`)
|
||||
if (!el) {
|
||||
el = document.createElement(tagName)
|
||||
el.autoplay = true
|
||||
el.playsInline = true
|
||||
if (tagName === 'video') {
|
||||
el.style.width = '100%'
|
||||
el.style.maxWidth = '240px'
|
||||
el.style.background = '#000'
|
||||
el.style.borderRadius = '4px'
|
||||
}
|
||||
if (className) el.className = className
|
||||
parent.appendChild(el)
|
||||
}
|
||||
return el
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return null
|
||||
// #endif
|
||||
}
|
||||
|
||||
const removeMediaEl = (container, tagName) => {
|
||||
// #ifdef H5
|
||||
const parent = resolveDom(container)
|
||||
if (!parent) return
|
||||
const el = parent.querySelector(`:scope > ${tagName}`)
|
||||
if (el) {
|
||||
try { el.srcObject = null } catch {}
|
||||
parent.removeChild(el)
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
// ==================== 本地/远端 track 挂载 ====================
|
||||
|
||||
const attachLocalTrack = () => {
|
||||
// #ifdef H5
|
||||
const track = meetingStore.getLocalTrack('video')
|
||||
if (track) {
|
||||
const el = ensureMediaEl(localVideoBox.value, 'video')
|
||||
if (el) {
|
||||
el.muted = true
|
||||
el.srcObject = new MediaStream([track])
|
||||
}
|
||||
} else {
|
||||
removeMediaEl(localVideoBox.value, 'video')
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
const attachRemoteTracks = () => {
|
||||
// #ifdef H5
|
||||
Object.keys(remoteConsumers.value).forEach((uid) => {
|
||||
const box = remoteBoxes[uid]
|
||||
if (!box) return
|
||||
const videoTrack = meetingStore.getRemoteTrack(Number(uid), 'video')
|
||||
const audioTrack = meetingStore.getRemoteTrack(Number(uid), 'audio')
|
||||
|
||||
if (videoTrack) {
|
||||
const videoEl = ensureMediaEl(box, 'video', 'remote-video')
|
||||
if (videoEl) videoEl.srcObject = new MediaStream([videoTrack])
|
||||
} else {
|
||||
removeMediaEl(box, 'video')
|
||||
}
|
||||
|
||||
if (audioTrack) {
|
||||
const audioEl = ensureMediaEl(box, 'audio', 'remote-audio')
|
||||
if (audioEl) audioEl.srcObject = new MediaStream([audioTrack])
|
||||
} else {
|
||||
removeMediaEl(box, 'audio')
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
watch(localVideoEnabled, () => nextTick(attachLocalTrack))
|
||||
watch(remoteConsumers, () => nextTick(attachRemoteTracks), { deep: true })
|
||||
watch(activeParticipants, () => nextTick(attachRemoteTracks))
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
const handleStaleHint = (err) => {
|
||||
if (err && err.staleMeetingHint) {
|
||||
lastHint.value = '检测到僵尸参会残留:请点击"清理遗留会议"按钮后重试'
|
||||
}
|
||||
}
|
||||
|
||||
const onCreate = async () => {
|
||||
lastHint.value = ''
|
||||
try {
|
||||
await meetingStore.createAndEnter({ title: form.title })
|
||||
uni.showToast({ title: '已创建并入会', icon: 'success' })
|
||||
} catch (err) {
|
||||
handleStaleHint(err)
|
||||
uni.showToast({ title: `创建失败:${err.message || JSON.stringify(err)}`, icon: 'none', duration: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
const onJoin = async () => {
|
||||
lastHint.value = ''
|
||||
try {
|
||||
await meetingStore.joinAndEnter(form.roomCode, form.password)
|
||||
uni.showToast({ title: '已加入会议', icon: 'success' })
|
||||
} catch (err) {
|
||||
handleStaleHint(err)
|
||||
uni.showToast({ title: `加入失败:${err.message || JSON.stringify(err)}`, icon: 'none', duration: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
const onCleanupStale = async () => {
|
||||
lastHint.value = ''
|
||||
try {
|
||||
const { cleanedCodes } = await meetingStore.cleanupStaleMeetings()
|
||||
if (cleanedCodes.length === 0) {
|
||||
lastHint.value = '无遗留活跃会议'
|
||||
uni.showToast({ title: '无遗留活跃会议', icon: 'none' })
|
||||
} else {
|
||||
lastHint.value = `已清理 ${cleanedCodes.length} 条:${cleanedCodes.join('、')}`
|
||||
uni.showToast({ title: `已清理 ${cleanedCodes.length} 条`, icon: 'success' })
|
||||
}
|
||||
} catch (err) {
|
||||
uni.showToast({ title: `清理失败:${err.message || JSON.stringify(err)}`, icon: 'none', duration: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
const onLeave = async () => {
|
||||
await meetingStore.leave()
|
||||
uni.showToast({ title: '已离会', icon: 'success' })
|
||||
}
|
||||
|
||||
const onEnd = async () => {
|
||||
try {
|
||||
await meetingStore.endMeeting()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: `结束失败:${err.message || JSON.stringify(err)}`, icon: 'none', duration: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleAudio = async () => {
|
||||
try {
|
||||
if (localAudioEnabled.value) await meetingStore.stopLocalAudio()
|
||||
else await meetingStore.startLocalAudio()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: `音频切换失败:${err.message}`, icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleVideo = async () => {
|
||||
try {
|
||||
if (localVideoEnabled.value) await meetingStore.stopLocalVideo()
|
||||
else await meetingStore.startLocalVideo()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: `视频切换失败:${err.message}`, icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const onSendChat = async () => {
|
||||
const content = chatInput.value.trim()
|
||||
if (!content) return
|
||||
try {
|
||||
await meetingStore.sendChat(content)
|
||||
chatInput.value = ''
|
||||
} catch (err) {
|
||||
uni.showToast({ title: `发送失败:${err.message || JSON.stringify(err)}`, icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (isInMeeting.value) {
|
||||
meetingStore.leave().catch(() => {})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 24rpx; background: #F8FAFC; min-height: 100vh; }
|
||||
.header { background: #fff; padding: 24rpx; border-radius: 12rpx; margin-bottom: 24rpx; }
|
||||
.title { font-size: 36rpx; font-weight: 600; color: #1E293B; display: block; margin-bottom: 12rpx; }
|
||||
.state { font-size: 26rpx; color: #475569; display: block; margin-top: 4rpx; }
|
||||
.section { background: #fff; padding: 24rpx; border-radius: 12rpx; margin-bottom: 24rpx; }
|
||||
.subtitle { font-size: 30rpx; font-weight: 600; color: #1E293B; display: block; margin-bottom: 16rpx; }
|
||||
.row { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; flex-wrap: wrap; }
|
||||
.input { flex: 1; min-width: 200rpx; border: 1px solid #CBD5E1; padding: 12rpx 16rpx; border-radius: 8rpx; font-size: 28rpx; }
|
||||
.input.password { max-width: 200rpx; }
|
||||
.btn { padding: 12rpx 24rpx; border-radius: 8rpx; background: #E2E8F0; color: #1E293B; font-size: 26rpx; }
|
||||
.btn.primary { background: #3B82F6; color: #fff; }
|
||||
.btn.danger { background: #EF4444; color: #fff; }
|
||||
.btn[disabled] { opacity: 0.5; }
|
||||
.video-wrap { display: flex; flex-direction: column; gap: 8rpx; margin-top: 16rpx; }
|
||||
.caption { font-size: 22rpx; color: #64748B; }
|
||||
.video-slot { width: 100%; min-height: 180rpx; background: #0B1220; border-radius: 8rpx; display: flex; align-items: center; justify-content: center; }
|
||||
.video-slot.small { min-height: 140rpx; }
|
||||
.participant { padding: 16rpx 0; border-bottom: 1px dashed #E2E8F0; }
|
||||
.pname { font-size: 24rpx; color: #475569; }
|
||||
.chat-box { max-height: 300rpx; overflow-y: auto; background: #F1F5F9; padding: 12rpx; border-radius: 8rpx; margin-bottom: 12rpx; }
|
||||
.chat-msg { font-size: 26rpx; color: #1E293B; padding: 4rpx 0; }
|
||||
.hint { font-size: 24rpx; color: #F59E0B; margin-top: 12rpx; display: block; }
|
||||
</style>
|
||||
@@ -18,6 +18,7 @@ const HEARTBEAT_INTERVAL = 30000
|
||||
const RECONNECT_BASE_DELAY = 1000
|
||||
const RECONNECT_MAX_DELAY = 30000
|
||||
const MAX_RECONNECT_ATTEMPTS = Infinity
|
||||
const ACK_DEFAULT_TIMEOUT_MS = 10000
|
||||
|
||||
class WebSocketService {
|
||||
constructor() {
|
||||
@@ -28,6 +29,11 @@ class WebSocketService {
|
||||
this.reconnectAttempts = 0
|
||||
this.isManualClose = false
|
||||
this.seq = 0
|
||||
/**
|
||||
* 等待 ACK 的请求索引:Map<seq, { resolve, reject, timer, event }>
|
||||
* Task 9 引入,为 meeting.* 事件提供 Promise 化 ACK 等待
|
||||
*/
|
||||
this.pendingAcks = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,6 +126,41 @@ class WebSocketService {
|
||||
return this.seq
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息并等待服务端 ACK(Task 9 引入)
|
||||
*
|
||||
* 服务端对 C→S 事件以相同 event 名 + 对应 seq 返回 ACK 报文:
|
||||
* { event, seq, data: { code, message, data? } }
|
||||
*
|
||||
* 约定:
|
||||
* - code === 0 表示成功,Promise resolve 为 data.data(业务 payload)
|
||||
* - code !== 0 表示业务失败,Promise reject 为 { code, message }
|
||||
* - 超时 reject 为 { code: -1, message: 'ACK 超时' }
|
||||
* - 连接未就绪 reject 为 { code: -1, message: '连接未就绪' }
|
||||
*
|
||||
* 典型用法:
|
||||
* const info = await wsService.sendWithAck('meeting.transport.create', { room_code, direction: 'send' })
|
||||
*
|
||||
* @param {string} event - 事件名
|
||||
* @param {Object} [data] - 业务 payload
|
||||
* @param {number} [timeoutMS=10000] - 超时时间
|
||||
* @returns {Promise<any>} ACK 的 data 字段
|
||||
*/
|
||||
sendWithAck(event, data = {}, timeoutMS = ACK_DEFAULT_TIMEOUT_MS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const seq = this.send(event, data)
|
||||
if (seq === -1) {
|
||||
reject({ code: -1, message: '连接未就绪' })
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingAcks.delete(seq)
|
||||
reject({ code: -1, message: `ACK 超时 (${event})` })
|
||||
}, timeoutMS)
|
||||
this.pendingAcks.set(seq, { resolve, reject, timer, event })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册事件监听
|
||||
* @param {string} event - 事件类型
|
||||
@@ -171,6 +212,7 @@ class WebSocketService {
|
||||
_onClose(e) {
|
||||
console.log('[WS] 连接关闭', e)
|
||||
this._clearTimers()
|
||||
this._rejectAllPendingAcks('连接已断开')
|
||||
this._emit('_disconnected')
|
||||
|
||||
if (!this.isManualClose) {
|
||||
@@ -178,6 +220,19 @@ class WebSocketService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接断开时统一拒绝所有未完成的 ACK 等待(Task 9)
|
||||
* 避免业务层 await sendWithAck() 永久挂起
|
||||
*/
|
||||
_rejectAllPendingAcks(reason) {
|
||||
if (this.pendingAcks.size === 0) return
|
||||
this.pendingAcks.forEach((pending, seq) => {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject({ code: -1, message: reason, event: pending.event })
|
||||
})
|
||||
this.pendingAcks.clear()
|
||||
}
|
||||
|
||||
_onError(e) {
|
||||
console.error('[WS] 连接错误', e)
|
||||
}
|
||||
@@ -185,12 +240,36 @@ class WebSocketService {
|
||||
_onMessage(data) {
|
||||
try {
|
||||
const msg = JSON.parse(data)
|
||||
// Task 9:ACK 报文(event 以 ".ack" 结尾)走 pendingAcks 处理
|
||||
// 后端 ws.NewResponse 固定在原 event 后追加 ".ack"
|
||||
if (typeof msg.event === 'string' && msg.event.endsWith('.ack')) {
|
||||
this._handleAck(msg)
|
||||
return
|
||||
}
|
||||
this._emit(msg.event, msg)
|
||||
} catch (e) {
|
||||
console.warn('[WS] 消息解析失败', data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 ACK 报文,触发 pendingAcks 中对应 seq 的 resolve/reject
|
||||
* @param {Object} msg - { event: 'xxx.ack', seq, code, message, data? }
|
||||
*/
|
||||
_handleAck(msg) {
|
||||
const pending = this.pendingAcks.get(msg.seq)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
this.pendingAcks.delete(msg.seq)
|
||||
clearTimeout(pending.timer)
|
||||
if (msg.code === 0) {
|
||||
pending.resolve(msg.data)
|
||||
} else {
|
||||
pending.reject({ code: msg.code, message: msg.message || 'WS 请求失败', event: pending.event })
|
||||
}
|
||||
}
|
||||
|
||||
/** 分发事件给注册的监听器 */
|
||||
_emit(event, data) {
|
||||
const cbs = this.listeners.get(event)
|
||||
|
||||
658
frontend/src/store/meeting.js
Normal file
658
frontend/src/store/meeting.js
Normal file
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* 会议模块 Pinia Store
|
||||
*
|
||||
* 职责:
|
||||
* - 管理当前入会状态(本地状态机:idle/joining/connecting/connected/reconnecting/leaving/ended)
|
||||
* - 维护房间信息、参与者列表、本地/远端媒体 Producer/Consumer
|
||||
* - 串联 REST API + WebSocket 信令 + mediasoup-client
|
||||
* - 监听 8 个服务端广播事件,实时更新 state
|
||||
*
|
||||
* 设计要点(Task 9):
|
||||
* - WS 事件监听仅在进入会议室时注册、离会时注销(决策 Q3=a3_on_enter_room)
|
||||
* - mediasoup 的 Device/Transport/Producer/Consumer 用 markRaw 避免 Vue 代理
|
||||
* - mediasoup-client 仅 H5 可用,其他平台调用 enterRoom 会抛错
|
||||
*
|
||||
* 上层调用模式(极简):
|
||||
* const meetingStore = useMeetingStore()
|
||||
* await meetingStore.createAndEnter({ title }) // 创建并入会
|
||||
* await meetingStore.joinAndEnter(roomCode, password) // 加入已有会议
|
||||
* await meetingStore.startLocalAudio()
|
||||
* await meetingStore.startLocalVideo()
|
||||
* await meetingStore.leave() // 离会
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, markRaw, reactive } from 'vue'
|
||||
|
||||
import meetingApi from '@/api/meeting'
|
||||
import wsService from '@/services/websocket'
|
||||
import { createMediaEngine } from '@/utils/mediasoup-client'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
import {
|
||||
MEETING_LOCAL_STATE_IDLE,
|
||||
MEETING_LOCAL_STATE_JOINING,
|
||||
MEETING_LOCAL_STATE_CONNECTING,
|
||||
MEETING_LOCAL_STATE_CONNECTED,
|
||||
MEETING_LOCAL_STATE_RECONNECTING,
|
||||
MEETING_LOCAL_STATE_LEAVING,
|
||||
MEETING_LOCAL_STATE_ENDED,
|
||||
MEETING_WS_ROOM_JOIN,
|
||||
MEETING_WS_ROOM_ENDED,
|
||||
MEETING_WS_MEMBER_JOINED,
|
||||
MEETING_WS_MEMBER_LEFT,
|
||||
MEETING_WS_MEMBER_STATE_CHANGED,
|
||||
MEETING_WS_MEMBER_KICKED,
|
||||
MEETING_WS_MEMBER_PRODUCER_NEW,
|
||||
MEETING_WS_HOST_CHANGED,
|
||||
MEETING_WS_CHAT_MESSAGE,
|
||||
MEETING_ROLE_HOST,
|
||||
MEETING_ENDED_REASON_LABEL,
|
||||
MEETING_HOST_AUTO_REASON_LABEL
|
||||
} from '@/constants/meeting'
|
||||
|
||||
/** 判断当前平台是否支持 mediasoup-client(仅 H5) */
|
||||
const isMediaSupported = () => {
|
||||
// #ifdef H5
|
||||
return true
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return false
|
||||
// #endif
|
||||
}
|
||||
|
||||
export const useMeetingStore = defineStore('meeting', () => {
|
||||
// ==================== State ====================
|
||||
|
||||
/** 本地状态机:MEETING_LOCAL_STATE_* */
|
||||
const localState = ref(MEETING_LOCAL_STATE_IDLE)
|
||||
|
||||
/** 当前会议房间 DTO(REST 响应中的 room 字段) */
|
||||
const currentRoom = ref(null)
|
||||
|
||||
/** 当前用户的 participant DTO */
|
||||
const currentParticipant = ref(null)
|
||||
|
||||
/** Router ID(Node mediasoup Router)—— 仅日志/调试用 */
|
||||
const routerID = ref('')
|
||||
|
||||
/** 活跃参与者列表(含当前用户,按加入时间正序) */
|
||||
const participants = ref([])
|
||||
|
||||
/** 会议聊天消息列表(最新在尾部) */
|
||||
const chatMessages = ref([])
|
||||
|
||||
/** 最近一次会议结束原因(room.ended_reason 或 room.ended.reason) */
|
||||
const lastEndedReason = ref('')
|
||||
|
||||
/** 最近一次自动主持人转让原因(auto_reason) */
|
||||
const lastAutoHostReason = ref('')
|
||||
|
||||
/** 本地媒体状态 */
|
||||
const localAudioEnabled = ref(false)
|
||||
const localVideoEnabled = ref(false)
|
||||
|
||||
/**
|
||||
* 当前用户的本地 Producer 索引:{ audio: producerId?, video: producerId? }
|
||||
* 用于启停麦克风/摄像头
|
||||
*/
|
||||
const localProducers = reactive({ audio: null, video: null })
|
||||
|
||||
/**
|
||||
* 远端 Consumer 索引:{ [userId]: { audio?: Consumer, video?: Consumer, producerIds: Set } }
|
||||
* 使用 reactive 但 Consumer 实例本身是 markRaw,不会被深度代理
|
||||
*/
|
||||
const remoteConsumers = reactive({})
|
||||
|
||||
/**
|
||||
* 标记:当前在 Store 内的事件监听器集合(进房注册,离房注销)
|
||||
* Key 是事件名,Value 是监听函数引用
|
||||
*/
|
||||
const _listeners = new Map()
|
||||
|
||||
/** MediaEngine 实例(H5 才有值),用 markRaw 包裹避免 Vue 深度代理 */
|
||||
let _engine = null
|
||||
|
||||
// ==================== Getters ====================
|
||||
|
||||
const isInMeeting = computed(() => {
|
||||
return localState.value !== MEETING_LOCAL_STATE_IDLE &&
|
||||
localState.value !== MEETING_LOCAL_STATE_ENDED
|
||||
})
|
||||
|
||||
const isConnected = computed(() => localState.value === MEETING_LOCAL_STATE_CONNECTED)
|
||||
|
||||
const isHost = computed(() => {
|
||||
const userStore = useUserStore()
|
||||
const uid = userStore.userInfo?.id
|
||||
return currentRoom.value && uid && currentRoom.value.host_id === uid
|
||||
})
|
||||
|
||||
const activeParticipants = computed(() => {
|
||||
return participants.value.filter(p => p.is_active)
|
||||
})
|
||||
|
||||
// ==================== 私有工具 ====================
|
||||
|
||||
const _log = (level, ...args) => {
|
||||
const fn = console[level] || console.log
|
||||
fn.call(console, ...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别"已在其他会议中"这类僵尸残留错误,加上提示前端可调用 cleanupStaleMeetings 的 hint
|
||||
* 保留原始 err 对象引用,仅附加 staleMeetingHint 字段供上层 UI 判定
|
||||
*/
|
||||
const _enrichStaleMeetingError = (err) => {
|
||||
if (!err) return err
|
||||
const msg = err.message || err.data?.message || ''
|
||||
if (msg.includes('已在其他会议中') || msg.includes('已在此会议中')) {
|
||||
err.staleMeetingHint = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
const _reset = () => {
|
||||
localState.value = MEETING_LOCAL_STATE_IDLE
|
||||
currentRoom.value = null
|
||||
currentParticipant.value = null
|
||||
routerID.value = ''
|
||||
participants.value = []
|
||||
chatMessages.value = []
|
||||
localAudioEnabled.value = false
|
||||
localVideoEnabled.value = false
|
||||
localProducers.audio = null
|
||||
localProducers.video = null
|
||||
Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k])
|
||||
_engine = null
|
||||
}
|
||||
|
||||
/** 统一注册 WS 监听(进房调用一次) */
|
||||
const _registerListeners = () => {
|
||||
const handlers = {
|
||||
[MEETING_WS_ROOM_ENDED]: _onRoomEnded,
|
||||
[MEETING_WS_MEMBER_JOINED]: _onMemberJoined,
|
||||
[MEETING_WS_MEMBER_LEFT]: _onMemberLeft,
|
||||
[MEETING_WS_MEMBER_STATE_CHANGED]: _onMemberStateChanged,
|
||||
[MEETING_WS_MEMBER_KICKED]: _onMemberKicked,
|
||||
[MEETING_WS_MEMBER_PRODUCER_NEW]: _onProducerNew,
|
||||
[MEETING_WS_HOST_CHANGED]: _onHostChanged,
|
||||
[MEETING_WS_CHAT_MESSAGE]: _onChatMessage
|
||||
}
|
||||
Object.entries(handlers).forEach(([event, handler]) => {
|
||||
wsService.on(event, handler)
|
||||
_listeners.set(event, handler)
|
||||
})
|
||||
}
|
||||
|
||||
const _unregisterListeners = () => {
|
||||
_listeners.forEach((handler, event) => {
|
||||
wsService.off(event, handler)
|
||||
})
|
||||
_listeners.clear()
|
||||
}
|
||||
|
||||
// ==================== WS 事件回调 ====================
|
||||
|
||||
const _onRoomEnded = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
lastEndedReason.value = data.reason || ''
|
||||
_log('info', '[Meeting] 会议已结束', data.reason, MEETING_ENDED_REASON_LABEL[data.reason])
|
||||
_cleanupMedia()
|
||||
localState.value = MEETING_LOCAL_STATE_ENDED
|
||||
_unregisterListeners()
|
||||
}
|
||||
|
||||
const _onMemberJoined = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
// 后端广播目前只带基础字段,完整 DTO 通过 GET /rooms/:code 二次拉取
|
||||
if (!participants.value.find(p => p.user_id === data.user_id)) {
|
||||
participants.value.push({
|
||||
user_id: data.user_id,
|
||||
joined_at: data.joined_at,
|
||||
is_active: true,
|
||||
role: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const _onMemberLeft = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
const idx = participants.value.findIndex(p => p.user_id === data.user_id)
|
||||
if (idx >= 0) {
|
||||
participants.value[idx].is_active = false
|
||||
participants.value[idx].left_reason = data.reason
|
||||
}
|
||||
_cleanupRemoteUser(data.user_id)
|
||||
}
|
||||
|
||||
const _onMemberStateChanged = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
const p = participants.value.find(p => p.user_id === data.user_id)
|
||||
if (p) {
|
||||
if (typeof data.audio_enabled === 'boolean') p.audio_enabled = data.audio_enabled
|
||||
if (typeof data.video_enabled === 'boolean') p.video_enabled = data.video_enabled
|
||||
}
|
||||
}
|
||||
|
||||
const _onMemberKicked = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
const userStore = useUserStore()
|
||||
if (data.user_id === userStore.userInfo?.id) {
|
||||
_log('warn', '[Meeting] 当前用户被踢出会议')
|
||||
lastEndedReason.value = 'kicked'
|
||||
_cleanupMedia()
|
||||
localState.value = MEETING_LOCAL_STATE_ENDED
|
||||
_unregisterListeners()
|
||||
} else {
|
||||
const idx = participants.value.findIndex(p => p.user_id === data.user_id)
|
||||
if (idx >= 0) {
|
||||
participants.value[idx].is_active = false
|
||||
participants.value[idx].left_reason = 'kicked'
|
||||
}
|
||||
_cleanupRemoteUser(data.user_id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到 meeting.member.producer.new 广播 → 自动订阅
|
||||
* 后端在 Producer 创建 / 关闭时都广播本事件,closed=true 表示关闭
|
||||
*/
|
||||
const _onProducerNew = async (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
const userStore = useUserStore()
|
||||
if (data.user_id === userStore.userInfo?.id) return
|
||||
|
||||
if (data.closed) {
|
||||
_cleanupRemoteProducer(data.user_id, data.producer_id)
|
||||
return
|
||||
}
|
||||
|
||||
if (!_engine) {
|
||||
_log('warn', '[Meeting] producer.new 收到但 MediaEngine 未就绪,跳过')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const consumer = await _engine.consume({ producerId: data.producer_id })
|
||||
if (!remoteConsumers[data.user_id]) {
|
||||
remoteConsumers[data.user_id] = markRaw({
|
||||
audio: null,
|
||||
video: null,
|
||||
producerIds: new Set()
|
||||
})
|
||||
}
|
||||
const slot = remoteConsumers[data.user_id]
|
||||
slot[consumer.kind] = markRaw(consumer)
|
||||
slot.producerIds.add(data.producer_id)
|
||||
|
||||
// track 已在 consumer.track 中,调用方(页面)可从 store 拿 consumer.track 挂到 DOM
|
||||
// 这里先做最简化的 resume:页面渲染后若还需等 DOM 就绪,可改为在页面层调 resumeConsumer
|
||||
await _engine.resumeConsumer(consumer.id)
|
||||
} catch (err) {
|
||||
_log('error', '[Meeting] 订阅远端 Producer 失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
const _onHostChanged = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
currentRoom.value.host_id = data.new_host_id
|
||||
lastAutoHostReason.value = data.auto_reason || ''
|
||||
_log('info', '[Meeting] 主持人变更',
|
||||
data.new_host_id,
|
||||
data.auto_reason ? `(auto: ${MEETING_HOST_AUTO_REASON_LABEL[data.auto_reason]})` : '(manual)')
|
||||
|
||||
const p = participants.value.find(p => p.user_id === data.new_host_id)
|
||||
if (p) p.role = MEETING_ROLE_HOST
|
||||
const old = participants.value.find(p => p.user_id === data.old_host_id)
|
||||
if (old) old.role = 0
|
||||
}
|
||||
|
||||
const _onChatMessage = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
chatMessages.value.push(data)
|
||||
}
|
||||
|
||||
// ==================== 远端媒体清理 ====================
|
||||
|
||||
const _cleanupRemoteProducer = (userId, producerId) => {
|
||||
const slot = remoteConsumers[userId]
|
||||
if (!slot) return
|
||||
slot.producerIds.delete(producerId)
|
||||
// 无法从 producerId 直接反查 consumer;简单策略:关 slot 内所有 consumer 然后重建(MVP)
|
||||
// 但更稳的:把 Consumer 索引按 producerId 存一遍
|
||||
;['audio', 'video'].forEach(kind => {
|
||||
const consumer = slot[kind]
|
||||
if (consumer && !consumer.closed) {
|
||||
try { consumer.close() } catch {}
|
||||
slot[kind] = null
|
||||
}
|
||||
})
|
||||
if (slot.producerIds.size === 0) {
|
||||
delete remoteConsumers[userId]
|
||||
}
|
||||
}
|
||||
|
||||
const _cleanupRemoteUser = (userId) => {
|
||||
const slot = remoteConsumers[userId]
|
||||
if (!slot) return
|
||||
;['audio', 'video'].forEach(kind => {
|
||||
if (slot[kind] && !slot[kind].closed) {
|
||||
try { slot[kind].close() } catch {}
|
||||
}
|
||||
})
|
||||
delete remoteConsumers[userId]
|
||||
}
|
||||
|
||||
const _cleanupMedia = () => {
|
||||
if (_engine) {
|
||||
try { _engine.close() } catch (e) { _log('warn', '_cleanupMedia engine.close', e) }
|
||||
_engine = null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 进入会议(公共私有) ====================
|
||||
|
||||
/**
|
||||
* 入会后的共同后置流程:加载 Device、创建两条 Transport、发 room.join 信令、订阅在线 Producer
|
||||
*/
|
||||
const _afterJoined = async ({ rtpCapabilities }) => {
|
||||
if (!isMediaSupported()) {
|
||||
throw new Error('当前平台不支持会议(仅支持 H5 浏览器)')
|
||||
}
|
||||
localState.value = MEETING_LOCAL_STATE_CONNECTING
|
||||
|
||||
const userStore = useUserStore()
|
||||
const uid = userStore.userInfo?.id
|
||||
if (!uid) throw new Error('当前用户未登录')
|
||||
_engine = markRaw(createMediaEngine({
|
||||
roomCode: currentRoom.value.room_code,
|
||||
userId: uid,
|
||||
sendWithAck: wsService.sendWithAck.bind(wsService),
|
||||
logger: _log
|
||||
}))
|
||||
await _engine.loadDevice(rtpCapabilities)
|
||||
|
||||
_registerListeners()
|
||||
|
||||
// 告知后端 WS 我已绑定本房间(用于 WS 连接 ↔ roomCode 映射)
|
||||
await wsService.sendWithAck(MEETING_WS_ROOM_JOIN, {
|
||||
room_code: currentRoom.value.room_code
|
||||
})
|
||||
|
||||
// 按需先创建 sendTransport + recvTransport,避免页面点"开麦"时首帧有额外抖动
|
||||
await _engine.ensureSendTransport()
|
||||
await _engine.ensureRecvTransport()
|
||||
|
||||
// 拉取最新参与者列表(广播里只有 ID,这里补齐完整 DTO)
|
||||
try {
|
||||
const detail = await meetingApi.getRoom(currentRoom.value.room_code)
|
||||
if (detail && Array.isArray(detail.participants)) {
|
||||
participants.value = detail.participants
|
||||
}
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 拉取参与者详情失败,保留广播缓存', e)
|
||||
}
|
||||
|
||||
localState.value = MEETING_LOCAL_STATE_CONNECTED
|
||||
}
|
||||
|
||||
// ==================== Actions:会议生命周期 ====================
|
||||
|
||||
/**
|
||||
* 创建即时会议并立即入会
|
||||
* @param {Object} payload - { title, password?, max_members? }
|
||||
*/
|
||||
const createAndEnter = async (payload) => {
|
||||
if (isInMeeting.value) {
|
||||
throw new Error('你当前已在其他会议中')
|
||||
}
|
||||
localState.value = MEETING_LOCAL_STATE_JOINING
|
||||
try {
|
||||
const resp = await meetingApi.createRoom(payload)
|
||||
currentRoom.value = resp.room
|
||||
routerID.value = resp.router_id || ''
|
||||
// 创建者 host participant 尚未通过 REST 返回,用本地 userStore 占位
|
||||
const userStore = useUserStore()
|
||||
currentParticipant.value = {
|
||||
user_id: userStore.userInfo?.id,
|
||||
role: MEETING_ROLE_HOST,
|
||||
is_active: true,
|
||||
joined_at: new Date().toISOString()
|
||||
}
|
||||
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities })
|
||||
return currentRoom.value
|
||||
} catch (err) {
|
||||
_log('error', '[Meeting] 创建并入会失败', err)
|
||||
_reset()
|
||||
throw _enrichStaleMeetingError(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入已有会议
|
||||
* @param {string} roomCode
|
||||
* @param {string} [password]
|
||||
*/
|
||||
const joinAndEnter = async (roomCode, password = '') => {
|
||||
if (isInMeeting.value) {
|
||||
throw new Error('你当前已在其他会议中')
|
||||
}
|
||||
localState.value = MEETING_LOCAL_STATE_JOINING
|
||||
try {
|
||||
const resp = await meetingApi.joinRoom(roomCode, { password })
|
||||
currentRoom.value = resp.room
|
||||
currentParticipant.value = resp.participant
|
||||
routerID.value = resp.router_id || ''
|
||||
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities })
|
||||
return currentRoom.value
|
||||
} catch (err) {
|
||||
_log('error', '[Meeting] 加入会议失败', err)
|
||||
_reset()
|
||||
throw _enrichStaleMeetingError(err)
|
||||
}
|
||||
}
|
||||
|
||||
/** 主动离会(关闭媒体 + REST leave) */
|
||||
const leave = async () => {
|
||||
if (!isInMeeting.value || !currentRoom.value) return
|
||||
localState.value = MEETING_LOCAL_STATE_LEAVING
|
||||
const code = currentRoom.value.room_code
|
||||
try {
|
||||
_cleanupMedia()
|
||||
_unregisterListeners()
|
||||
await meetingApi.leaveRoom(code)
|
||||
} catch (err) {
|
||||
_log('warn', '[Meeting] leave API 失败(忽略)', err)
|
||||
} finally {
|
||||
_reset()
|
||||
}
|
||||
}
|
||||
|
||||
/** 主持人结束会议(会广播 room.ended 给所有人) */
|
||||
const endMeeting = async () => {
|
||||
if (!currentRoom.value) return
|
||||
const code = currentRoom.value.room_code
|
||||
try {
|
||||
await meetingApi.endRoom(code)
|
||||
} catch (err) {
|
||||
_log('warn', '[Meeting] end API 失败', err)
|
||||
throw err
|
||||
}
|
||||
// room.ended WS 广播会触发 _onRoomEnded 清理
|
||||
}
|
||||
|
||||
// ==================== Actions:本地推流 ====================
|
||||
|
||||
/** 开启本地音频 */
|
||||
const startLocalAudio = async () => {
|
||||
if (!_engine) throw new Error('未连接')
|
||||
if (localProducers.audio) return
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
const track = stream.getAudioTracks()[0]
|
||||
const producer = await _engine.produce({ kind: 'audio', track })
|
||||
localProducers.audio = producer.id
|
||||
localAudioEnabled.value = true
|
||||
}
|
||||
|
||||
/** 开启本地视频 */
|
||||
const startLocalVideo = async () => {
|
||||
if (!_engine) throw new Error('未连接')
|
||||
if (localProducers.video) return
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } })
|
||||
const track = stream.getVideoTracks()[0]
|
||||
const producer = await _engine.produce({ kind: 'video', track })
|
||||
localProducers.video = producer.id
|
||||
localVideoEnabled.value = true
|
||||
}
|
||||
|
||||
/** 关闭本地音频 */
|
||||
const stopLocalAudio = async () => {
|
||||
if (!_engine || !localProducers.audio) return
|
||||
await _engine.closeProducer(localProducers.audio)
|
||||
localProducers.audio = null
|
||||
localAudioEnabled.value = false
|
||||
}
|
||||
|
||||
/** 关闭本地视频 */
|
||||
const stopLocalVideo = async () => {
|
||||
if (!_engine || !localProducers.video) return
|
||||
await _engine.closeProducer(localProducers.video)
|
||||
localProducers.video = null
|
||||
localVideoEnabled.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得某个远端用户的 track(供页面挂到 <video>/<audio>)
|
||||
* @param {number} userId
|
||||
* @param {'audio'|'video'} kind
|
||||
* @returns {MediaStreamTrack|null}
|
||||
*/
|
||||
const getRemoteTrack = (userId, kind) => {
|
||||
const slot = remoteConsumers[userId]
|
||||
if (!slot || !slot[kind]) return null
|
||||
return slot[kind].track
|
||||
}
|
||||
|
||||
/** 取得本地 Producer 对应的 track(预览用) */
|
||||
const getLocalTrack = (kind) => {
|
||||
if (!_engine) return null
|
||||
const producerId = localProducers[kind]
|
||||
if (!producerId) return null
|
||||
const producer = _engine.getProducer(producerId)
|
||||
return producer ? producer.track : null
|
||||
}
|
||||
|
||||
// ==================== Actions:聊天/管理 ====================
|
||||
|
||||
const sendChat = async (content) => {
|
||||
if (!currentRoom.value) throw new Error('未入会')
|
||||
await meetingApi.sendChat(currentRoom.value.room_code, content)
|
||||
}
|
||||
|
||||
const loadChatHistory = async (beforeId = 0, limit = 30) => {
|
||||
if (!currentRoom.value) return { list: [], has_more: false }
|
||||
return meetingApi.listChats(currentRoom.value.room_code, { before_id: beforeId, limit })
|
||||
}
|
||||
|
||||
const transferHost = async (targetUserID) => {
|
||||
if (!currentRoom.value) throw new Error('未入会')
|
||||
await meetingApi.transferHost(currentRoom.value.room_code, targetUserID)
|
||||
}
|
||||
|
||||
const kickMember = async (userId) => {
|
||||
if (!currentRoom.value) throw new Error('未入会')
|
||||
await meetingApi.kickMember(currentRoom.value.room_code, userId)
|
||||
}
|
||||
|
||||
const inviteUsers = async (inviteeIds) => {
|
||||
if (!currentRoom.value) throw new Error('未入会')
|
||||
return meetingApi.inviteUsers(currentRoom.value.room_code, inviteeIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理遗留的僵尸活跃会议
|
||||
*
|
||||
* 使用场景:前端崩溃 / 强制刷新 / 关闭标签页未调 `/leave` 时,后端
|
||||
* `meeting_participants` 表仍标记用户为活跃参会者,再次创建/加入会议会
|
||||
* 被后端以"你当前已在其他会议中"拒绝。本方法拉取用户全部 `status=Active`
|
||||
* 会议并依次调用 `/leave`,顺带处理可能多条的僵尸记录(比如 DAO 异常)。
|
||||
*
|
||||
* @returns {Promise<{ cleanedCodes: string[] }>}
|
||||
*/
|
||||
const cleanupStaleMeetings = async () => {
|
||||
const resp = await meetingApi.listMyMeetings({ status: 1, limit: 50 })
|
||||
const list = (resp && resp.list) || []
|
||||
const cleanedCodes = []
|
||||
for (const room of list) {
|
||||
try {
|
||||
await meetingApi.leaveRoom(room.room_code)
|
||||
cleanedCodes.push(room.room_code)
|
||||
} catch (err) {
|
||||
_log('warn', '[Meeting] 清理遗留会议失败(忽略继续)', room.room_code, err)
|
||||
}
|
||||
}
|
||||
if (isInMeeting.value) {
|
||||
// 本地状态可能因 leave 网络错误等处于脏状态,一并 reset
|
||||
_reset()
|
||||
}
|
||||
return { cleanedCodes }
|
||||
}
|
||||
|
||||
// ==================== 导出 ====================
|
||||
|
||||
return {
|
||||
// state
|
||||
localState,
|
||||
currentRoom,
|
||||
currentParticipant,
|
||||
routerID,
|
||||
participants,
|
||||
chatMessages,
|
||||
lastEndedReason,
|
||||
lastAutoHostReason,
|
||||
localAudioEnabled,
|
||||
localVideoEnabled,
|
||||
localProducers,
|
||||
remoteConsumers,
|
||||
|
||||
// getters
|
||||
isInMeeting,
|
||||
isConnected,
|
||||
isHost,
|
||||
activeParticipants,
|
||||
|
||||
// lifecycle
|
||||
createAndEnter,
|
||||
joinAndEnter,
|
||||
leave,
|
||||
endMeeting,
|
||||
|
||||
// media
|
||||
startLocalAudio,
|
||||
startLocalVideo,
|
||||
stopLocalAudio,
|
||||
stopLocalVideo,
|
||||
getRemoteTrack,
|
||||
getLocalTrack,
|
||||
|
||||
// chat & admin
|
||||
sendChat,
|
||||
loadChatHistory,
|
||||
transferHost,
|
||||
kickMember,
|
||||
inviteUsers,
|
||||
cleanupStaleMeetings,
|
||||
|
||||
// util
|
||||
isMediaSupported
|
||||
}
|
||||
})
|
||||
408
frontend/src/utils/mediasoup-client.js
Normal file
408
frontend/src/utils/mediasoup-client.js
Normal 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(...) 得到本地 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)
|
||||
}
|
||||
}
|
||||
|
||||
/** 本地 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)
|
||||
}
|
||||
Reference in New Issue
Block a user