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:
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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user