视频会议保存

This commit is contained in:
duoaohui
2026-05-18 19:42:55 +08:00
parent 315884685f
commit ac8ec4c903
41 changed files with 5069 additions and 39 deletions

View File

@@ -46,6 +46,14 @@ import {
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,
@@ -103,10 +111,57 @@ export const useMeetingStore = defineStore('meeting', () => {
const localVideoEnabled = ref(false)
/**
* 当前用户的本地 Producer 索引:{ audio: producerId?, video: producerId? }
* 用于启停麦克风/摄像头
* 当前用户的本地 Producer 索引:{ audio: producerId?, video: producerId?, screen: producerId? }
* 用于启停麦克风/摄像头/屏幕共享
*
* Phase 3 屏幕共享screen 与 video 同为 video kind但走独立 Producer
* appData.screen=true 区分),便于单独开关,互不影响摄像头
*/
const localProducers = reactive({ audio: null, video: null })
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 } }
@@ -254,6 +309,10 @@ export const useMeetingStore = defineStore('meeting', () => {
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
}
@@ -269,6 +328,10 @@ export const useMeetingStore = defineStore('meeting', () => {
[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
@@ -299,6 +362,10 @@ export const useMeetingStore = defineStore('meeting', () => {
// 表现为"重新发起会议后看不到自己的画面"
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])
@@ -446,16 +513,20 @@ export const useMeetingStore = defineStore('meeting', () => {
try {
const consumer = await _engine.consume({ producerId: data.producer_id })
if (!remoteConsumers[data.user_id]) {
// 外层 slot 保持 reactive 以便 slot.audio/slot.video 赋值能驱动 VideoTile 重渲染;
// 外层 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]
slot[consumer.kind] = markRaw(consumer)
// 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
@@ -466,6 +537,116 @@ export const useMeetingStore = defineStore('meeting', () => {
}
}
/**
* 收到 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
}
/**
* 收到 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
@@ -563,7 +744,8 @@ export const useMeetingStore = defineStore('meeting', () => {
const slot = remoteConsumers[userId]
if (!slot) return
slot.producerIds.delete(producerId)
;['audio', 'video'].forEach(kind => {
// Phase 3新增 screen 槽位一并扫描,关闭归属 producerId 的那一个
;['audio', 'video', 'screen'].forEach(kind => {
const consumer = slot[kind]
if (!consumer) return
if (consumer.producerId && consumer.producerId !== producerId) return
@@ -572,7 +754,7 @@ export const useMeetingStore = defineStore('meeting', () => {
}
slot[kind] = null
})
if (!slot.audio && !slot.video && slot.producerIds.size === 0) {
if (!slot.audio && !slot.video && !slot.screen && slot.producerIds.size === 0) {
delete remoteConsumers[userId]
}
}
@@ -580,12 +762,18 @@ export const useMeetingStore = defineStore('meeting', () => {
const _cleanupRemoteUser = (userId) => {
const slot = remoteConsumers[userId]
if (!slot) return
;['audio', 'video'].forEach(kind => {
;['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 = () => {
@@ -1039,6 +1227,153 @@ export const useMeetingStore = defineStore('meeting', () => {
_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('未获取到屏幕共享视频流')
}
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
@@ -1099,7 +1434,7 @@ export const useMeetingStore = defineStore('meeting', () => {
/**
* 取得某个远端用户的 track供页面挂到 <video>/<audio>
* @param {number} userId
* @param {'audio'|'video'} kind
* @param {'audio'|'video'|'screen'} kind - Phase 3 起新增 'screen' 槽位
* @returns {MediaStreamTrack|null}
*/
const getRemoteTrack = (userId, kind) => {
@@ -1243,6 +1578,7 @@ export const useMeetingStore = defineStore('meeting', () => {
localAudioEnabled,
localVideoEnabled,
localProducers,
screenShare,
remoteConsumers,
draftCreatePayload,
draftJoinPayload,
@@ -1269,6 +1605,11 @@ export const useMeetingStore = defineStore('meeting', () => {
startLocalVideo,
stopLocalAudio,
stopLocalVideo,
startScreenShare,
stopScreenShare,
recording,
startRecording,
stopRecording,
getRemoteTrack,
getLocalTrack,