视频会议保存
This commit is contained in:
@@ -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.started(existing=true)
|
||||
*/
|
||||
const screenShare = reactive({ ownerUserId: null, producerId: null })
|
||||
|
||||
/**
|
||||
* 会议录制状态(Phase B)
|
||||
*
|
||||
* 字段:
|
||||
* - id 后端 meeting_recordings.id;null 表示当前无录制
|
||||
* - 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 状态
|
||||
*
|
||||
* 数据驱动而非直接操作 consumer:consumer 已经由 _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 而不是 producer:producer 的 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 Producer(MediaEngine 内部触发 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 录制 actions(Phase 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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user