Files
EchoChat/frontend/src/store/meeting.js
2026-05-19 17:21:40 +08:00

1642 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 会议模块 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_WS_SCREEN_STARTED,
MEETING_WS_SCREEN_STOPPED,
MEETING_WS_RECORDING_STARTED,
MEETING_WS_RECORDING_STOPPED,
MEETING_RECORDING_STATUS_RECORDING,
MEETING_RECORDING_STATUS_UPLOADING,
MEETING_RECORDING_STATUS_READY,
MEETING_RECORDING_STATUS_FAILED,
MEETING_ROLE_HOST,
MEETING_ENDED_REASON_LABEL,
MEETING_ENDED_REASON_KICKED,
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)
/** 当前会议房间 DTOREST 响应中的 room 字段) */
const currentRoom = ref(null)
/** 当前用户的 participant DTO */
const currentParticipant = ref(null)
/** Router IDNode mediasoup Router—— 仅日志/调试用 */
const routerID = ref('')
/** 活跃参与者列表(含当前用户,按加入时间正序) */
const participants = ref([])
/** 会议聊天消息列表(按 ID 升序,最新在尾部) */
const chatMessages = ref([])
/** 会议聊天还有更早消息可加载(用于"加载更多"按钮) */
const chatHasMore = ref(true)
/** 会议聊天未读计数(来自他人且 ChatPanel 未打开期间累积) */
const chatUnreadCount = ref(0)
/** 会议聊天首次懒加载完成标记 */
const chatHistoryLoaded = ref(false)
/** 最近一次会议结束原因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?, screen: producerId? }
* 用于启停麦克风/摄像头/屏幕共享
*
* Phase 3 屏幕共享screen 与 video 同为 video kind但走独立 Producer
* appData.screen=true 区分),便于单独开关,互不影响摄像头
*/
const localProducers = reactive({ audio: null, video: null, screen: null })
/**
* 当前会议屏幕共享状态Phase 3
* - ownerUserId: 谁在共享含自己null 表示无人共享
* - producerId: 屏幕流 Producer ID远端用于匹配 consumer slot本地用于回查
*
* 数据来源:
* 1) 自己开启startScreenShare 成功后由 OnProduceStart 触发 meeting.screen.started 广播写入
* 2) 他人开启:收到 meeting.screen.started 后写入
* 3) 后入者OnRoomJoin 后端会定向补推 meeting.screen.startedexisting=true
*/
const screenShare = reactive({ ownerUserId: null, producerId: null })
/**
* 会议录制状态Phase B
*
* 字段:
* - id 后端 meeting_recordings.idnull 表示当前无录制
* - status recording / uploading / ready / failed
* - startedBy 发起录制的 host userId便于 UI 提示"由 XXX 录制"
* - startedAt unix 秒
* - stoppedAt unix 秒
* - fileUrl 最终回放 URL仅 ready
* - sizeBytes 文件大小(仅 ready
* - durationSec 录制时长(仅 ready
* - failureReason 失败原因(仅 failed
* - pending 本地"启停按钮防抖"标记:调 REST 期间禁用按钮,避免重复请求
*
* 数据流:
* - 进入会议时不主动拉历史录制;只通过 WS meeting.recording.started/stopped 维护
* - 同时只允许 1 条活跃录制 → 单对象足够,不必维护数组
*/
const recording = reactive({
id: null,
status: null,
startedBy: null,
startedAt: null,
stoppedAt: null,
fileUrl: '',
sizeBytes: 0,
durationSec: 0,
failureReason: '',
pending: false
})
/**
* 远端 Consumer 索引:{ [userId]: { audio?: Consumer, video?: Consumer, producerIds: Set } }
* 使用 reactive 但 Consumer 实例本身是 markRaw不会被深度代理
*/
const remoteConsumers = reactive({})
/**
* 创建会议表单草稿Task 10
* create.vue 填完表单后暂存于此preview.vue 点"加入会议"时才真正调用 createAndEnter。
* 字段:{ title, password?, enter_muted, allow_chat }
*/
const draftCreatePayload = ref(null)
/**
* 加入会议密码草稿Task 16 P0-4 修复)
* 原实现把密码明文拼在 uni.navigateTo 的 URL query 里,浏览器历史 / DevTools 都会留痕,
* 违背设计文档 §2.2.1 "邀请链接仅携带 token 不携带密码"的约束。
* 新流程join.vue 填完密码 → 写入本字段 → 只跳转 `?mode=join&code=XXX-XXX-XXX` → preview.vue 读取后立即清空。
* 字段:{ code, password } 或 null
*/
const draftJoinPayload = ref(null)
/**
* 设备预览页的设备选择与显示名Task 10
* preview.vue 选完设备后写入,入会时作为 mediaPrefs 传给 createAndEnter / joinAndEnter
* 字段:{ audioDeviceId, videoDeviceId, speakerDeviceId, displayName, startAudio, startVideo }
*/
const devicePreview = ref({
audioDeviceId: '',
videoDeviceId: '',
speakerDeviceId: '',
displayName: '',
startAudio: true,
startVideo: true
})
/**
* 标记:当前在 Store 内的事件监听器集合(进房注册,离房注销)
* Key 是事件名Value 是监听函数引用
*/
const _listeners = new Map()
/** MediaEngine 实例H5 才有值),用 markRaw 包裹避免 Vue 深度代理 */
let _engine = null
/**
* 说话者探测:{ [userId]: boolean },由 UI 订阅驱动 VideoTile.speaking 边框
* Task 15 原创特色 1——EchoChat 会议专属流光轮廓
*/
const speakingMap = reactive({})
/**
* UI 偏好(内存态,不持久化到 storage
* - selfVideoFloat: 自视频是否走浮窗模式(桌面端默认 true
*/
const uiPrefs = reactive({
selfVideoFloat: true
})
/**
* 说话者探测内部状态handle + 本地 WebAudio 实例
* 所有字段均为 H5 浏览器 API非 H5 平台永远保持 null
*/
let _speakingTimer = null
let _localAudioCtx = null
let _localAnalyser = null
let _localSource = null
/** { [userId]: { inCount: number, outCount: number } },用于防抖 1-in / 2-out */
const _speakingDebounce = {}
/**
* Task 16 资源清理专项_broadcastSelfState 重试期间的 setTimeout 句柄集合
* 离会 / 结束会议时统一 clearTimeout避免会议结束后仍有 stale 重试逻辑在 event loop 排队
*/
const _pendingBroadcastTimers = new Set()
// ==================== 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)
})
/**
* 全员静音标记:用于 Task 15 原创特色 4——静音氛围色
* 规则:仅当参与者 ≥ 2 人且所有人 audio_enabled=false 时 true
* 单人会议不触发(独自一人不算"全员静音氛围"
*/
const isAllMuted = computed(() => {
const list = activeParticipants.value
if (list.length < 2) return false
const userStore = useUserStore()
const myUid = userStore.userInfo?.id
return list.every(p => {
// 本地 audio 以 localAudioEnabled 为准(后端不会给自己广播 state.changed
if (myUid && p.user_id === myUid) return !localAudioEnabled.value
return p.audio_enabled === false
})
})
// ==================== 私有工具 ====================
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 = []
chatHasMore.value = true
chatUnreadCount.value = 0
chatHistoryLoaded.value = false
localAudioEnabled.value = false
localVideoEnabled.value = false
localProducers.audio = null
localProducers.video = null
localProducers.screen = null
screenShare.ownerUserId = null
screenShare.producerId = null
_resetRecordingState()
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,
[MEETING_WS_SCREEN_STARTED]: _onScreenStarted,
[MEETING_WS_SCREEN_STOPPED]: _onScreenStopped,
[MEETING_WS_RECORDING_STARTED]: _onRecordingStarted,
[MEETING_WS_RECORDING_STOPPED]: _onRecordingStopped,
// WS 连接生命周期:用于断线/重连期间维持会议会话,避免"连接断开 → 后端失去 roomCode 绑定 → 广播丢失"
_connected: _onWsReconnected,
_disconnected: _onWsDisconnected
}
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()
// 清理 producer/consumer 索引与本地开关,防止下次 createAndEnter/joinAndEnter 时
// startLocalAudio/Video 因 `if (localProducers.audio) return` 被残留 ID 拦下,
// 表现为"重新发起会议后看不到自己的画面"
localProducers.audio = null
localProducers.video = null
localProducers.screen = null
screenShare.ownerUserId = null
screenShare.producerId = null
_resetRecordingState()
localAudioEnabled.value = false
localVideoEnabled.value = false
Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k])
localState.value = MEETING_LOCAL_STATE_ENDED
_unregisterListeners()
}
/**
* 合并 REST getRoom 返回的 participants 与当前内存状态
* - REST DTO 目前不包含 audio_enabled/video_enabled后端 DTO 未暴露该字段)
* - WS 广播驱动的内存状态p.audio_enabled / p.video_enabled可能已更新REST 覆盖会回退
* - 策略:以 REST 返回的静态属性为主,保留本地已知的音视频开关状态;首次未知时留 undefined
* 交由 state.changed 广播权威填充,避免误将"未知"冲成 false 引起 UI 错判
*/
const _mergeParticipantsFromRest = (restList) => {
const byUid = new Map()
participants.value.forEach((p) => byUid.set(p.user_id, p))
return restList.map((p) => {
const prev = byUid.get(p.user_id)
const merged = { ...p }
if (typeof p.audio_enabled !== 'boolean' && prev && typeof prev.audio_enabled === 'boolean') {
merged.audio_enabled = prev.audio_enabled
}
if (typeof p.video_enabled !== 'boolean' && prev && typeof prev.video_enabled === 'boolean') {
merged.video_enabled = prev.video_enabled
}
return merged
})
}
const _onMemberJoined = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
// 后端广播已带 user_name/user_avatar直接补齐昵称头像字段即可
const existed = participants.value.find(p => p.user_id === data.user_id)
if (existed) {
if (data.user_name) existed.user_name = data.user_name
if (data.user_avatar) existed.user_avatar = data.user_avatar
existed.is_active = true
return
}
// Task 15新入会者的 audio_enabled/video_enabled 保持 undefined
// 等 startLocalAudio/Video 成功后的 meeting.member.state.changed 广播权威填充
// MemberPanel 对 undefined 按 !p.audio_enabled 解读为 off语义"尚未开启"与预期一致
participants.value.push({
user_id: data.user_id,
user_name: data.user_name || '',
user_avatar: data.user_avatar || '',
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
let p = participants.value.find(p => p.user_id === data.user_id)
if (!p) {
// 时序兜底OnRoomJoin 触发的 existing state.changed 可能早于 REST getRoom 返回
// 此时 participants 里还没有 data.user_id 条目。建个占位,等后续 REST / member.joined
// 在 _mergeParticipantsFromRest 里补齐 user_name/avatar 即可,本处先把状态保留住
p = {
user_id: data.user_id,
user_name: '',
user_avatar: '',
is_active: true,
role: 0
}
participants.value.push(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
// Task 15主持人通过 meeting.member.state.changed 带 target_user_id 指令静音他人,
// 后端广播字段名参见 meeting_signal_service.OnMemberStateChanged
// data.user_id = 被操作者(即当前收到广播的用户)
// data.changed_by = 发起操作者(主持人)
const userStore = useUserStore()
const myUid = userStore.userInfo?.id
const isTargetMe = myUid && data.user_id === myUid
const isByOthers = data.changed_by && data.changed_by !== myUid
if (isTargetMe && isByOthers && typeof data.audio_enabled === 'boolean' && !data.audio_enabled) {
// 被主持人请求静音 → 立即关闭本地 audio producer
if (localAudioEnabled.value) {
stopLocalAudio().catch((err) => _log('warn', '[Meeting] 被动静音失败', err))
}
// #ifdef H5
try { uni.showToast({ title: '主持人请你静音', icon: 'none', duration: 2000 }) } catch {}
// #endif
}
}
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 = MEETING_ENDED_REASON_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]) {
// 外层 slot 保持 reactive 以便 slot.audio/slot.video/slot.screen 赋值能驱动 VideoTile 重渲染;
// 仅 Consumer 实例和 Set 本身用 markRaw 隔离 Vue 代理
remoteConsumers[data.user_id] = {
audio: null,
video: null,
screen: null,
producerIds: markRaw(new Set())
}
}
const slot = remoteConsumers[data.user_id]
// Phase 3 屏幕共享:后端 producer.new 携带 screen=true 时挂到独立 screen 槽位,
// 避免覆盖摄像头 video 槽。consumer.kind 仍是 'video',不能用 kind 区分
const slotKey = data.screen ? 'screen' : consumer.kind
slot[slotKey] = 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)
}
}
/**
* 收到 meeting.screen.started 广播 → 更新 screenShare 状态
*
* 数据驱动而非直接操作 consumerconsumer 已经由 _onProducerNew 自动建好(带 screen=true 路由到 slot.screen
* 本 handler 只负责标记"当前谁在共享 + 哪个 producer"UI 层据此把屏幕 tile 提升为大画面
*
* 兼容路径existing=true 表示后端 OnRoomJoin 内部补推(后入者首次连进来时拉取当前状态)
*/
const _onScreenStarted = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
screenShare.ownerUserId = data.user_id
screenShare.producerId = data.producer_id
_log('info', '[Meeting] 屏幕共享已开始', data.user_id, data.producer_id, data.existing ? '(existing)' : '')
}
/**
* 收到 meeting.screen.stopped 广播 → 清空 screenShare 状态
*
* 幂等保护:若当前 owner 不是 data.user_id说明已被新的 started 覆盖),不做处理
* 远端屏幕 Consumer 的清理由 meeting.member.producer.new(closed=true) 复用 _cleanupRemoteProducer 完成,
* 本 handler 不重复关闭,避免 race
*/
const _onScreenStopped = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
if (screenShare.ownerUserId && screenShare.ownerUserId !== data.user_id) {
_log('debug', '[Meeting] 忽略过期 screen.stopped', data.user_id)
return
}
screenShare.ownerUserId = null
screenShare.producerId = null
_log('info', '[Meeting] 屏幕共享已停止', data.user_id)
}
// ==================== 录制相关辅助 / 回调Phase B ====================
/** 重置 recording 状态_reset / _cleanupMedia / room.ended 复用) */
const _resetRecordingState = () => {
recording.id = null
recording.status = null
recording.startedBy = null
recording.startedAt = null
recording.stoppedAt = null
recording.fileUrl = ''
recording.sizeBytes = 0
recording.durationSec = 0
recording.failureReason = ''
recording.pending = false
}
/**
* 用 server 返回的 recording DTO 合并到本地 recording reactive
* 注意:服务端字段是 snake_case前端 store 字段是 camelCase
*/
const _applyRecordingDTO = (dto) => {
if (!dto || typeof dto !== 'object') return
if (dto.id != null) recording.id = dto.id
if (dto.status) recording.status = dto.status
if (dto.started_by != null) recording.startedBy = dto.started_by
if (dto.started_at != null) recording.startedAt = dto.started_at
if (dto.stopped_at != null) recording.stoppedAt = dto.stopped_at
if (typeof dto.file_url === 'string') recording.fileUrl = dto.file_url
if (typeof dto.size_bytes === 'number') recording.sizeBytes = dto.size_bytes
if (typeof dto.duration_sec === 'number') recording.durationSec = dto.duration_sec
if (typeof dto.failure_reason === 'string') recording.failureReason = dto.failure_reason
if (
dto.status === MEETING_RECORDING_STATUS_RECORDING ||
dto.status === MEETING_RECORDING_STATUS_READY ||
dto.status === MEETING_RECORDING_STATUS_FAILED
) {
recording.pending = false
}
}
/**
* 收到 meeting.recording.started 广播
* 后端 payload: { room_code, recording_id, started_by, started_at, status='recording' }
*/
const _onRecordingStarted = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
// 与本地状态合并:可能本地刚 startRecording 拿到 DTO 后又收到广播id 必须一致才合并)
if (recording.id && recording.id !== data.recording_id) {
_log('warn', '[Meeting] 忽略不一致的 recording.started', data.recording_id, '当前', recording.id)
return
}
recording.id = data.recording_id
recording.status = data.status || MEETING_RECORDING_STATUS_RECORDING
recording.startedBy = data.started_by
recording.startedAt = data.started_at
recording.pending = false
_log('info', '[Meeting] 录制已开始', data.recording_id, data.started_by)
}
/**
* 收到 meeting.recording.stopped 广播
* payload: { room_code, recording_id, status: ready|failed, file_url?, size_bytes?, duration_sec?, failure_reason?, stopped_at }
* status=uploading 阶段后端不广播;前端只需在 ready/failed 时清理 pending、保留终态展示
*/
const _onRecordingStopped = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
if (recording.id && recording.id !== data.recording_id) {
_log('warn', '[Meeting] 忽略不一致的 recording.stopped', data.recording_id, '当前', recording.id)
return
}
recording.status = data.status || MEETING_RECORDING_STATUS_READY
recording.stoppedAt = data.stopped_at || Math.floor(Date.now() / 1000)
if (typeof data.file_url === 'string') recording.fileUrl = data.file_url
if (typeof data.size_bytes === 'number') recording.sizeBytes = data.size_bytes
if (typeof data.duration_sec === 'number') recording.durationSec = data.duration_sec
if (typeof data.failure_reason === 'string') recording.failureReason = data.failure_reason
recording.pending = false
_log('info', '[Meeting] 录制已停止', data.recording_id, data.status, data.file_url)
}
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
// WS 广播字段名与 REST 响应不同id vs message_id统一映射为 id/user_id/content/created_at
const item = {
id: data.message_id ?? data.id,
user_id: data.user_id,
user_name: data.user_name || '',
user_avatar: data.user_avatar || '',
content: data.content,
created_at: data.created_at
}
// 防止重复:相同 id 已存在则忽略sendChat 响应 + 广播同时到达的兜底)
if (item.id && chatMessages.value.some(m => m.id === item.id)) return
chatMessages.value.push(item)
const userStore = useUserStore()
if (item.user_id !== userStore.userInfo?.id) {
chatUnreadCount.value += 1
}
}
/**
* WS 连接断开仅当当前已进入会议CONNECTED/CONNECTING时切换到 RECONNECTING
* 供 UI 展示"重连中…",避免用户误以为自己已离会。
*/
const _onWsDisconnected = () => {
if (!currentRoom.value) return
if (
localState.value === MEETING_LOCAL_STATE_CONNECTED ||
localState.value === MEETING_LOCAL_STATE_CONNECTING
) {
_log('warn', '[Meeting] WS 已断开,进入 reconnecting')
localState.value = MEETING_LOCAL_STATE_RECONNECTING
}
}
/**
* WS 重连成功:若当前仍在某个房间内,自动重发 meeting.room.join 以恢复后端 WS↔roomCode 绑定。
* 后端 WS Handler 对同一用户重连后,需要再次收到 meeting.room.join 才会将该连接加入房间广播组,
* 否则即使 mediasoup 层仍在线,所有 meeting.* 广播都不会再送达本端。
*/
const _onWsReconnected = async () => {
if (!currentRoom.value) return
// 仅在 RECONNECTING 状态触发(避免首次进房 _onOpen 与 _afterJoined 的首次 join 并发冲突)
if (localState.value !== MEETING_LOCAL_STATE_RECONNECTING) return
const roomCode = currentRoom.value.room_code
try {
_log('info', '[Meeting] WS 重连后重绑定房间', roomCode)
await wsService.sendWithAck(MEETING_WS_ROOM_JOIN, { room_code: roomCode })
// 重连期间可能错过若干广播,重新拉一次详情对齐参与者列表
try {
const detail = await meetingApi.getRoom(roomCode)
if (detail && Array.isArray(detail.participants)) {
participants.value = _mergeParticipantsFromRest(detail.participants)
}
} catch (e) {
_log('warn', '[Meeting] 重绑定后拉取房间详情失败', e)
}
localState.value = MEETING_LOCAL_STATE_CONNECTED
_log('info', '[Meeting] WS 重绑定房间成功,恢复 connected')
} catch (err) {
_log('warn', '[Meeting] WS 重绑定房间失败,保持 reconnecting 等待下次重连', err)
// 保留 RECONNECTING 状态,下一次 WS 重连会再次触发本回调
}
}
// ==================== 远端媒体清理 ====================
/**
* 精确关闭"某用户某个 producer 对应的 consumer"
*
* mediasoup-client 的 Consumer 对象自带 `producerId` 字段transport.consume 时注入),
* 利用它匹配 slot.audio / slot.video 中归属 producerId 的那一个;
* 其他 kind 的 consumer 必须保留,否则会出现"关音频连带把视频画面也关掉"的连锁问题。
*
* 找不到匹配时(例如 producerId 异常),不再做 MVP 时期的"整槽关闭"兜底,
* 而是只把 producerIds Set 条目移掉;真正该关的 consumer 由后续 transportclose / 离会清理接管
*/
const _cleanupRemoteProducer = (userId, producerId) => {
const slot = remoteConsumers[userId]
if (!slot) return
slot.producerIds.delete(producerId)
// Phase 3新增 screen 槽位一并扫描,关闭归属 producerId 的那一个
;['audio', 'video', 'screen'].forEach(kind => {
const consumer = slot[kind]
if (!consumer) return
if (consumer.producerId && consumer.producerId !== producerId) return
if (!consumer.closed) {
try { consumer.close() } catch {}
}
slot[kind] = null
})
if (!slot.audio && !slot.video && !slot.screen && slot.producerIds.size === 0) {
delete remoteConsumers[userId]
}
}
const _cleanupRemoteUser = (userId) => {
const slot = remoteConsumers[userId]
if (!slot) return
;['audio', 'video', 'screen'].forEach(kind => {
if (slot[kind] && !slot[kind].closed) {
try { slot[kind].close() } catch {}
}
})
delete remoteConsumers[userId]
// Phase 3若离开者恰好是当前屏幕共享者本地兜底清空 screenShare
// (正常路径下后端的 cleanupUserResources 会广播 screen.stopped此处只是 race 防御)
if (screenShare.ownerUserId === userId) {
screenShare.ownerUserId = null
screenShare.producerId = null
}
}
const _cleanupMedia = () => {
_stopSpeakingDetection()
// Task 16 资源清理:清理 _broadcastSelfState 挂起的 setTimeout 重试
// 防止会议结束后重试逻辑仍在 event loop 排队造成 memory pin
_pendingBroadcastTimers.forEach((handle) => {
try { clearTimeout(handle) } catch {}
})
_pendingBroadcastTimers.clear()
if (_engine) {
try { _engine.close() } catch (e) { _log('warn', '_cleanupMedia engine.close', e) }
_engine = null
}
}
// ==================== 说话者探测Task 15 原创特色 1 ====================
/**
* 音量阈值0-1 线性RMS / audioLevel 均归一化到此区间)
* 0.05 为静默阈值比正常说话0.15-0.4)低 3-8 倍,可过滤键盘敲击背景噪声
*/
const _SPEAKING_LEVEL_THRESHOLD = 0.05
/** 探测轮询间隔ms500ms 已足够识别说话停顿 */
const _SPEAKING_TICK_MS = 500
/** 读取远端 Consumer 的即时音量0-1 */
const _readRemoteAudioLevel = (consumer) => {
// #ifdef H5
if (!consumer || !consumer.rtpReceiver) return 0
try {
const sources = consumer.rtpReceiver.getSynchronizationSources?.() || []
if (sources.length === 0) return 0
// audioLevel 是 0-1 的浮点数W3C SpecChrome/Edge 原生支持
// Firefox 可能返回 undefined此时回退为 0不影响其他远端检测
const level = sources[0].audioLevel
return typeof level === 'number' ? level : 0
} catch {
return 0
}
// #endif
// #ifndef H5
return 0
// #endif
}
/** 读取本地 AnalyserNode 的 RMS0-1 */
const _readLocalAudioLevel = () => {
// #ifdef H5
if (!_localAnalyser) return 0
try {
const buf = new Float32Array(_localAnalyser.fftSize)
_localAnalyser.getFloatTimeDomainData(buf)
let sum = 0
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]
const rms = Math.sqrt(sum / buf.length)
// RMS 天然偏小,乘 3 放大到与 audioLevel 相似数量级
return Math.min(1, rms * 3)
} catch {
return 0
}
// #endif
// #ifndef H5
return 0
// #endif
}
/**
* 防抖更新level >= 阈值 1 tick 即判定 speaking=true
* level < 阈值连续 2 tick 才 false避免说话时短暂停顿 UI 闪烁
*/
const _updateSpeakingWithDebounce = (userId, level) => {
if (!_speakingDebounce[userId]) {
_speakingDebounce[userId] = { inCount: 0, outCount: 0 }
}
const state = _speakingDebounce[userId]
const above = level >= _SPEAKING_LEVEL_THRESHOLD
if (above) {
state.inCount += 1
state.outCount = 0
if (state.inCount >= 1 && !speakingMap[userId]) {
speakingMap[userId] = true
}
} else {
state.inCount = 0
state.outCount += 1
if (state.outCount >= 2 && speakingMap[userId]) {
speakingMap[userId] = false
}
}
}
/** 初始化本地 WebAudio 管道(仅在有本地 audio track 时建) */
const _ensureLocalAnalyser = () => {
// #ifdef H5
const track = getLocalTrack('audio')
if (!track) {
_teardownLocalAnalyser()
return
}
// 若已有管道且 track 未变,直接复用;否则重建(例:麦克风切换设备)
if (_localAnalyser && _localSource && _localSource.mediaStream?.getAudioTracks()[0]?.id === track.id) {
return
}
_teardownLocalAnalyser()
try {
const AC = window.AudioContext || window.webkitAudioContext
if (!AC) return
_localAudioCtx = new AC()
const stream = new MediaStream([track])
_localSource = _localAudioCtx.createMediaStreamSource(stream)
_localAnalyser = _localAudioCtx.createAnalyser()
_localAnalyser.fftSize = 1024
_localSource.connect(_localAnalyser)
} catch (e) {
_log('warn', '[Meeting] 本地 AnalyserNode 初始化失败', e)
_teardownLocalAnalyser()
}
// #endif
}
const _teardownLocalAnalyser = () => {
// #ifdef H5
try { _localSource?.disconnect?.() } catch {}
try { _localAnalyser?.disconnect?.() } catch {}
try { _localAudioCtx?.close?.() } catch {}
_localSource = null
_localAnalyser = null
_localAudioCtx = null
// #endif
}
/** 500ms 轮询:计算所有远端 + 本地的音量并防抖更新 speakingMap */
const _speakingTick = () => {
const userStore = useUserStore()
const myUid = userStore.userInfo?.id
// 远端:遍历所有有 audio consumer 的用户
Object.keys(remoteConsumers).forEach(uidStr => {
const uid = Number(uidStr)
const slot = remoteConsumers[uid]
const level = slot?.audio ? _readRemoteAudioLevel(slot.audio) : 0
_updateSpeakingWithDebounce(uid, level)
})
// 本地:仅在本地 audio 开启时探测
if (myUid) {
if (localAudioEnabled.value) {
_ensureLocalAnalyser()
const level = _readLocalAudioLevel()
_updateSpeakingWithDebounce(myUid, level)
} else {
// 关麦后直接清零并销毁 AnalyserNode
if (speakingMap[myUid]) speakingMap[myUid] = false
if (_speakingDebounce[myUid]) _speakingDebounce[myUid] = { inCount: 0, outCount: 0 }
_teardownLocalAnalyser()
}
}
}
const _startSpeakingDetection = () => {
if (_speakingTimer) return
// #ifdef H5
_speakingTimer = setInterval(_speakingTick, _SPEAKING_TICK_MS)
// #endif
}
const _stopSpeakingDetection = () => {
if (_speakingTimer) {
clearInterval(_speakingTimer)
_speakingTimer = null
}
_teardownLocalAnalyser()
Object.keys(speakingMap).forEach(k => delete speakingMap[k])
Object.keys(_speakingDebounce).forEach(k => delete _speakingDebounce[k])
}
// ==================== 进入会议(公共私有) ====================
/**
* 入会后的共同后置流程:加载 Device、创建两条 Transport、发 room.join 信令、订阅在线 Producer
* @param {Object} opts
* @param {Object} opts.rtpCapabilities - Router RTP capabilities
* @param {Object} [opts.mediaPrefs] - 设备预览页传入的媒体偏好,入会成功后自动推流
* - startAudio: boolean 是否默认开麦
* - startVideo: boolean 是否默认开摄像头
* - audioDeviceId / videoDeviceId: 指定采集设备(可空→走系统默认)
*/
const _afterJoined = async ({ rtpCapabilities, mediaPrefs }) => {
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()
// 提前拉取参与者列表:让 OnRoomJoin 触发的 existing state.changed / producer.new
// 到达时 participants 已就绪,避免后入者的 _onMemberStateChanged 找不到 participant 而丢状态
try {
const detail = await meetingApi.getRoom(currentRoom.value.room_code)
if (detail && Array.isArray(detail.participants)) {
participants.value = _mergeParticipantsFromRest(detail.participants)
}
} catch (e) {
_log('warn', '[Meeting] 拉取参与者详情失败,保留广播缓存', e)
}
// 告知后端 WS 我已绑定本房间(用于 WS 连接 ↔ roomCode 映射)
// 后端在此事件内部异步 pushExistingRoomState补推房间其他用户的 producer.new 与 state.changed
await wsService.sendWithAck(MEETING_WS_ROOM_JOIN, {
room_code: currentRoom.value.room_code
})
// 按需先创建 sendTransport + recvTransport避免页面点"开麦"时首帧有额外抖动
await _engine.ensureSendTransport()
await _engine.ensureRecvTransport()
localState.value = MEETING_LOCAL_STATE_CONNECTED
// 启动说话者探测Task 15500ms 轮询,远端读 RTP audioLevel本地用 WebAudio RMS
_startSpeakingDetection()
// mediaPrefs 自动推流:失败仅告警,不阻断入会流程,允许用户在会议室内手动重试开麦/开摄像头
// 预览页已申请过 track 时会通过 mediaPrefs.audioTrack/videoTrack 透传过来,避免 iOS 二次 getUserMedia
if (mediaPrefs) {
if (mediaPrefs.startAudio) {
try {
await startLocalAudio(mediaPrefs.audioDeviceId, mediaPrefs.audioTrack)
} catch (e) {
_log('warn', '[Meeting] 自动开麦失败', e)
// #ifdef H5
try { uni.showToast({ title: `自动开麦失败:${e?.message || e?.name || e}`, icon: 'none', duration: 3500 }) } catch {}
// #endif
}
}
if (mediaPrefs.startVideo) {
try {
await startLocalVideo(mediaPrefs.videoDeviceId, mediaPrefs.videoTrack)
} catch (e) {
_log('warn', '[Meeting] 自动开摄像头失败', e)
// #ifdef H5
try { uni.showToast({ title: `自动开摄像头失败:${e?.message || e?.name || e}`, icon: 'none', duration: 3500 }) } catch {}
// #endif
}
}
}
}
// ==================== Actions会议生命周期 ====================
/**
* 创建即时会议并立即入会
* @param {Object} payload - { title, password?, max_members?, enter_muted?, allow_chat? }
* @param {Object} [mediaPrefs] - 设备预览页的媒体偏好,入会成功后自动推流(可空)
*/
const createAndEnter = async (payload, mediaPrefs, _retried = false) => {
if (isInMeeting.value) {
throw new Error('你当前已在其他会议中')
}
// 上一次会议若以 ENDED 收尾(被主持人/后端结束Store 中仍会残留 participants/remoteConsumers/
// devicePreview 等旧状态;显式 _reset 一次避免污染新会议(典型症状:再次入会看不到自己)
_reset()
localState.value = MEETING_LOCAL_STATE_JOINING
try {
// 首次调用时抑制失败 toast失败后若是 stale 残留会自动清理 + 重试,对用户无感
const resp = await meetingApi.createRoom(payload, _retried ? {} : { silent: true })
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, mediaPrefs })
return currentRoom.value
} catch (err) {
_log('error', '[Meeting] 创建并入会失败', err)
_reset()
const enriched = _enrichStaleMeetingError(err)
// 刷新/崩溃残留:后端仍标记当前用户为活跃成员 → 自动清理后重试一次
if (enriched?.staleMeetingHint && !_retried) {
_log('warn', '[Meeting] 检测到僵尸会议残留,自动清理并重试创建')
try { await cleanupStaleMeetings() } catch (e) { _log('warn', '[Meeting] 自动清理失败', e) }
return createAndEnter(payload, mediaPrefs, true)
}
throw enriched
}
}
/**
* 加入已有会议
* @param {string} roomCode
* @param {string} [password]
* @param {Object} [mediaPrefs] - 设备预览页的媒体偏好(可空)
*/
const joinAndEnter = async (roomCode, password = '', mediaPrefs, _retried = false) => {
if (isInMeeting.value) {
throw new Error('你当前已在其他会议中')
}
// 与 createAndEnter 同理:消除上一次 ENDED 会议残留的 Store 状态
_reset()
localState.value = MEETING_LOCAL_STATE_JOINING
try {
// 首次调用时抑制失败 toast失败后若是 stale 残留会自动清理 + 重试,对用户无感
const resp = await meetingApi.joinRoom(roomCode, { password }, _retried ? {} : { silent: true })
currentRoom.value = resp.room
currentParticipant.value = resp.participant
routerID.value = resp.router_id || ''
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities, mediaPrefs })
return currentRoom.value
} catch (err) {
_log('error', '[Meeting] 加入会议失败', err)
_reset()
const enriched = _enrichStaleMeetingError(err)
// 刷新/崩溃残留:后端仍记该用户为活跃成员 → 自动清理后重试一次
if (enriched?.staleMeetingHint && !_retried) {
_log('warn', '[Meeting] 检测到僵尸会议残留,自动清理并重试加入')
try { await cleanupStaleMeetings() } catch (e) { _log('warn', '[Meeting] 自动清理失败', e) }
return joinAndEnter(roomCode, password, mediaPrefs, true)
}
throw enriched
}
}
/** 主动离会(关闭媒体 + 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 给所有人)
* Task 16 资源清理API 成功后立即本地兜底 _cleanupMedia
* - 避免"API 成功但 WS 断线导致 room.ended 广播丢失 → 本地 engine/timer/AudioContext 泄漏"
* - 同时触发 _onRoomEnded 走 ENDED 状态UI 展示"会议已结束"提示页
* - 若之后广播到达也是幂等_engine 已 null + closed 判定)
*/
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
}
_onRoomEnded({ data: { room_code: code, reason: 'host_ended' } })
}
/**
* Task 16 资源清理:退出"会议已结束"提示页时的清理
* - _onRoomEnded 刻意保留了 currentRoom / participants / chatMessages 供结束页渲染
* - 但若用户长时间停留在结束页(或直接 reLaunch 去别处pinia state 会一直占内存
* - 调用方room.vue 的 onUnload / 结束页"返回首页"按钮
* - 仅在 ENDED 状态下执行 _reset其他状态用 leave() 通道
*/
const exitEndedRoom = () => {
if (localState.value === MEETING_LOCAL_STATE_ENDED) {
_reset()
}
}
// ==================== Actions本地推流 ====================
/**
* 开启本地音频
* @param {string} [deviceId] - 指定麦克风 deviceId从 enumerateDevices 获取),为空则走系统默认
*/
const startLocalAudio = async (deviceId, existingTrack) => {
if (!_engine) throw new Error('未连接')
if (localProducers.audio) return
// 优先复用预览页已申请的 track避开 iOS Safari/Chrome 连续两次 getUserMedia
// 触发 NotReadableError预览页 track.stop() 后相机硬件有释放延迟)
let track = existingTrack && existingTrack.readyState === 'live' ? existingTrack : null
if (!track) {
const audioConstraints = deviceId ? { deviceId: { exact: deviceId } } : true
const stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints })
track = stream.getAudioTracks()[0]
}
const producer = await _engine.produce({ kind: 'audio', track })
localProducers.audio = producer.id
localAudioEnabled.value = true
_broadcastSelfState({ audio_enabled: true })
}
/**
* 开启本地视频
* @param {string} [deviceId] - 指定摄像头 deviceId为空则走系统默认
*/
const startLocalVideo = async (deviceId, existingTrack) => {
if (!_engine) throw new Error('未连接')
if (localProducers.video) return
// 优先复用预览页已申请的 track避开 iOS Safari/Chrome 连续两次 getUserMedia
// 触发 NotReadableError预览页 track.stop() 后相机硬件有释放延迟)
let track = existingTrack && existingTrack.readyState === 'live' ? existingTrack : null
if (!track) {
const videoConstraints = {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 24, max: 30 },
...(deviceId ? { deviceId: { exact: deviceId } } : {})
}
const stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints })
track = stream.getVideoTracks()[0]
}
const producer = await _engine.produce({ kind: 'video', track })
localProducers.video = producer.id
localVideoEnabled.value = true
_broadcastSelfState({ video_enabled: true })
}
/** 关闭本地音频 */
const stopLocalAudio = async () => {
if (!_engine || !localProducers.audio) return
await _engine.closeProducer(localProducers.audio)
localProducers.audio = null
localAudioEnabled.value = false
_broadcastSelfState({ audio_enabled: false })
}
/** 关闭本地视频 */
const stopLocalVideo = async () => {
if (!_engine || !localProducers.video) return
await _engine.closeProducer(localProducers.video)
localProducers.video = null
localVideoEnabled.value = false
_broadcastSelfState({ video_enabled: false })
}
/**
* 开启屏幕共享Phase 3
*
* 流程:
* 1) 前置校验:会议是否已有他人在共享(前端拦截,后端 OnProduceStart 也会兜底拒绝)
* 2) navigator.mediaDevices.getDisplayMedia 弹出系统选择器(仅 H5/桌面浏览器支持)
* 3) MediaEngine.produce 携带 appData={screen:true},让后端打 owner key + 广播 screen.started
* 4) 监听 track.onended用户点浏览器原生"停止共享"按钮时自动调 stopScreenShare 清理
*
* 不与摄像头互斥:摄像头开启时仍可叠加屏幕共享,两者各自占独立 Producer
*
* 失败处理getDisplayMedia 用户取消、设备不支持、后端拒绝等均抛错给调用方提示 toast
* produce 失败时主动 stop() track 释放屏幕采集,避免幽灵采集
*/
const startScreenShare = async () => {
if (!_engine) throw new Error('未连接')
if (localProducers.screen) return
if (typeof navigator === 'undefined' ||
!navigator.mediaDevices ||
typeof navigator.mediaDevices.getDisplayMedia !== 'function') {
throw new Error('当前浏览器不支持屏幕共享')
}
const myUid = useUserStore().userInfo?.id
if (screenShare.ownerUserId && screenShare.ownerUserId !== myUid) {
throw new Error('已有成员正在共享屏幕')
}
const stream = await navigator.mediaDevices.getDisplayMedia({
// 屏幕共享对帧率要求低、清晰度要求高,控制带宽优先保证清晰度
video: { frameRate: { ideal: 15, max: 30 } },
audio: false
})
const track = stream.getVideoTracks()[0]
if (!track) {
// 极端情况:用户授权了但 track 为空,主动 stop 整个 stream
try { stream.getTracks().forEach(t => t.stop()) } catch {}
throw new Error('未获取到屏幕共享视频流')
}
// contentHint='detail' 提示浏览器编码器按"清晰度优先"策略:
// 倾向保留高分辨率与文字锐度,降低不必要的帧率/运动估计开销,对 PPT/代码屏共享体验更好。
// 仅设置不报错的浏览器Chromium 系全部支持),不支持时静默忽略。
try { track.contentHint = 'detail' } catch {}
let producer
try {
producer = await _engine.produce({
kind: 'video',
track,
appData: { screen: true }
})
} catch (err) {
// produce 失败必须主动 stop() track否则浏览器顶部"正在共享屏幕"提示条会一直显示
try { track.stop() } catch {}
throw err
}
localProducers.screen = producer.id
// 浏览器原生"停止共享"按钮:用户从系统 UI 取消时 track 进入 ended
// 此时本地 producer.on('trackended') 也会触发关闭,这里加一层保险
// 注意监听 track 而不是 producerproducer 的 trackended handler 已经做了 closeProducer
// 本 listener 主要负责前端 store 状态收尾localProducers.screen 清空、广播由后端发)
track.addEventListener('ended', () => {
_log('info', '[Meeting] 屏幕共享 track 已结束(用户从浏览器原生 UI 取消)')
// 双保险:调一次 stopScreenShare幂等
stopScreenShare().catch((err) => _log('warn', '[Meeting] 自动停止屏幕共享失败', err))
})
}
/**
* 停止屏幕共享Phase 3
* - 关闭本地 screen ProducerMediaEngine 内部触发 producer.close WS 通知后端)
* - 后端 OnProducerClose 检测到这是 screenOwnerKey 对应的 producer会广播 screen.stopped
* - 本地 screenShare 状态由 _onScreenStopped 广播 handler 兜底清空(保证幂等)
* 幂等:未在共享时静默返回
*/
const stopScreenShare = async () => {
if (!_engine || !localProducers.screen) return
const pid = localProducers.screen
// 先清本地引用,防止 producer.close 走完前 UI 重复触发
localProducers.screen = null
try {
await _engine.closeProducer(pid)
} catch (err) {
_log('warn', '[Meeting] 关闭屏幕共享 Producer 失败(忽略,后端会兜底清理)', err)
}
}
// ==================== 录制 actionsPhase B ====================
/**
* 启动录制(仅 host 可调用)
* - REST 调用 startRecording → 后端 200 后会广播 meeting.recording.started
* - 本 action 在 REST 成功时同步本地 recording 状态pending=true 期间禁用按钮)
* - 失败时 recording.pending 置回 false错误向上抛由 UI toast
*/
const startRecording = async () => {
if (!currentRoom.value) {
throw new Error('当前未在会议中')
}
if (recording.pending) {
_log('debug', '[Meeting] startRecording 防抖:已有 pending 请求')
return
}
if (recording.id && recording.status === MEETING_RECORDING_STATUS_RECORDING) {
_log('debug', '[Meeting] startRecording当前已在录制跳过')
return
}
recording.pending = true
try {
const dto = await meetingApi.startRecording(currentRoom.value.room_code)
_applyRecordingDTO(dto)
_log('info', '[Meeting] 录制启动成功', dto?.id)
return dto
} catch (err) {
recording.pending = false
_log('error', '[Meeting] 录制启动失败', err)
throw err
}
}
/**
* 停止录制(仅 host 可调用)
* - REST 返回时录制已经处于 uploading异步上传 MinIO广播 stopped 在 ready/failed 终态触发
* - 本 action 仅负责发起 REST + pending 标记UI 等待广播终态
*/
const stopRecording = async () => {
if (!currentRoom.value) {
throw new Error('当前未在会议中')
}
if (!recording.id) {
_log('debug', '[Meeting] stopRecording当前无活跃录制')
return
}
if (recording.pending) {
_log('debug', '[Meeting] stopRecording 防抖:已有 pending 请求')
return
}
recording.pending = true
try {
const dto = await meetingApi.stopRecording(currentRoom.value.room_code, recording.id)
_applyRecordingDTO(dto)
// pending 保持 true由 _onRecordingStopped 广播置回 false终态
_log('info', '[Meeting] 录制停止已发起,等待终态广播', dto?.id, dto?.status)
return dto
} catch (err) {
recording.pending = false
_log('error', '[Meeting] 录制停止失败', err)
throw err
}
}
/**
* Task 15向房间其他成员广播自身音视频状态
* - 后端 meeting.member.state.changed 语义:操作自己无需 target_user_id
* - 后端会广播给房间其他成员(不回显给本人)
*
* Task 16 P1-6增加 sendWithAck 重试与 member_state Hash 最终一致性保障
* - 旧版失败仅 warnWS 抖动期其他成员的"图标灰色"问题会重现
* - 现实现最多 2 次指数退避重试700ms → 2100ms所有重试失败再输出 error 日志
* - 设计约束:总耗时上限 ~6s避免用户切换过快时累积旧状态覆盖新状态
* (每次调用前先记录 targetSnapshot重试前若 currentRoom 已变则放弃;
* 若 patch 同字段已被后续 broadcast 覆盖,也放弃旧 patch 的重试)
*/
const _broadcastSelfState = (patch) => {
if (!currentRoom.value) return
if (!patch || typeof patch !== 'object') return
const payload = { room_code: currentRoom.value.room_code }
if (typeof patch.audio_enabled === 'boolean') payload.audio_enabled = patch.audio_enabled
if (typeof patch.video_enabled === 'boolean') payload.video_enabled = patch.video_enabled
if (Object.keys(payload).length <= 1) return
const targetRoomCode = currentRoom.value.room_code
const maxAttempts = 3
const backoffMs = [0, 700, 2100]
const attemptSend = (attempt) => {
if (!currentRoom.value || currentRoom.value.room_code !== targetRoomCode) {
_log('info', '[Meeting] 切换会议/已离会,放弃 state 重试', payload)
return
}
if (typeof patch.audio_enabled === 'boolean' && localAudioEnabled.value !== patch.audio_enabled) {
_log('info', '[Meeting] audio_enabled 已被后续动作覆盖,放弃旧 patch 重试', patch)
return
}
if (typeof patch.video_enabled === 'boolean' && localVideoEnabled.value !== patch.video_enabled) {
_log('info', '[Meeting] video_enabled 已被后续动作覆盖,放弃旧 patch 重试', patch)
return
}
wsService.sendWithAck(MEETING_WS_MEMBER_STATE_CHANGED, payload, 3000)
.catch((err) => {
if (attempt + 1 >= maxAttempts) {
_log('error', '[Meeting] 上报本地音视频状态失败(已用尽重试)', err, payload)
return
}
const delay = backoffMs[attempt + 1] || 2100
_log('warn', `[Meeting] 上报本地音视频状态失败,${delay}ms 后重试 (${attempt + 2}/${maxAttempts})`, err)
// Task 16 资源清理:把 setTimeout 句柄登记到全局 Set
// 离会/结束会议时 _cleanupMedia() 统一 clearTimeout避免 stale 重试排队
const handle = setTimeout(() => {
_pendingBroadcastTimers.delete(handle)
attemptSend(attempt + 1)
}, delay)
_pendingBroadcastTimers.add(handle)
})
}
attemptSend(0)
}
/**
* 取得某个远端用户的 track供页面挂到 <video>/<audio>
* @param {number} userId
* @param {'audio'|'video'|'screen'} kind - Phase 3 起新增 'screen' 槽位
* @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聊天/管理 ====================
/**
* 发送会议聊天
* 后端广播 meeting.chat.message 给房间内非发送者,发送方依赖 REST 响应里的 message 上屏;
* 若后端未把 message 字段回传,则退化为等待广播(不上屏)
*/
const sendChat = async (content) => {
if (!currentRoom.value) throw new Error('未入会')
const resp = await meetingApi.sendChat(currentRoom.value.room_code, content)
const msg = resp?.message
if (msg && !chatMessages.value.some(m => m.id === msg.id)) {
chatMessages.value.push(msg)
}
return msg
}
/** 加载更早的聊天消息,按 ID 游标向前翻页;首次进入时 beforeId=0 表示拿最新一页 */
const loadMoreChats = async (limit = 30) => {
if (!currentRoom.value) return { list: [], has_more: false }
const oldest = chatMessages.value[0]
const beforeId = oldest?.id || 0
const resp = await meetingApi.listChats(currentRoom.value.room_code, { before_id: beforeId, limit })
const list = (resp?.list || []).slice().sort((a, b) => (a.id || 0) - (b.id || 0))
if (list.length) {
// 去重后按时间序插入到头部
const existing = new Set(chatMessages.value.map(m => m.id))
const fresh = list.filter(m => !existing.has(m.id))
if (fresh.length) chatMessages.value.unshift(...fresh)
}
chatHasMore.value = !!resp?.has_more
chatHistoryLoaded.value = true
return resp
}
/** 读掉所有会议聊天未读ChatPanel 打开时调用) */
const markChatsRead = () => {
chatUnreadCount.value = 0
}
/** 兼容旧调用:保留 loadChatHistory 名称,内部调 loadMoreChats */
const loadChatHistory = loadMoreChats
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)
}
/**
* 主持人静音他人 / 请他开麦Task 15 主持人权限四件套第 4 件)
*
* 通过 WS meeting.member.state.changed 携带 target_user_id
* 后端已实现 host 校验与广播逻辑meeting_signal_service.go前端仅需发送。
*
* @param {number} targetUserId 被操作对象 user_id
* @param {boolean} mute true=静音false=请他开麦
*/
const muteMember = async (targetUserId, mute = true) => {
if (!currentRoom.value) throw new Error('未入会')
if (!isHost.value) throw new Error('仅主持人可操作')
await wsService.sendWithAck(MEETING_WS_MEMBER_STATE_CHANGED, {
room_code: currentRoom.value.room_code,
target_user_id: targetUserId,
audio_enabled: !mute
}, 3000)
}
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 {
// silent: true —— 清理流程中 leave 失败(如 "你不在此会议中"
// 属于幂等预期错误,不应弹 toast 打扰用户
await meetingApi.leaveRoom(room.room_code, { silent: true })
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,
chatHasMore,
chatUnreadCount,
chatHistoryLoaded,
lastEndedReason,
lastAutoHostReason,
localAudioEnabled,
localVideoEnabled,
localProducers,
screenShare,
remoteConsumers,
draftCreatePayload,
draftJoinPayload,
devicePreview,
speakingMap,
uiPrefs,
// getters
isInMeeting,
isConnected,
isHost,
activeParticipants,
isAllMuted,
// lifecycle
createAndEnter,
joinAndEnter,
leave,
endMeeting,
exitEndedRoom,
// media
startLocalAudio,
startLocalVideo,
stopLocalAudio,
stopLocalVideo,
startScreenShare,
stopScreenShare,
recording,
startRecording,
stopRecording,
getRemoteTrack,
getLocalTrack,
// chat & admin
sendChat,
loadMoreChats,
loadChatHistory,
markChatsRead,
transferHost,
kickMember,
muteMember,
inviteUsers,
cleanupStaleMeetings,
// util
isMediaSupported
}
})