视频会议保存
This commit is contained in:
@@ -29,6 +29,16 @@
|
||||
<view class="state-dot"></view>
|
||||
<text>{{ stateText }}</text>
|
||||
</view>
|
||||
<!-- Phase B 录制指示器:所有人可见,点击对 host 触发停止;非 host 仅展示 -->
|
||||
<view
|
||||
v-if="isRecording || recordingLoading"
|
||||
class="recording-indicator"
|
||||
:class="{ uploading: recordingLoading && !isRecording }"
|
||||
:title="recordingHintText"
|
||||
>
|
||||
<view class="rec-dot"></view>
|
||||
<text class="rec-text">{{ isRecording ? 'REC' : '上传中' }}</text>
|
||||
</view>
|
||||
<NetworkBadge :level="networkLevel" />
|
||||
<text class="timer">{{ durationText }}</text>
|
||||
</view>
|
||||
@@ -36,8 +46,41 @@
|
||||
|
||||
<!-- 主区:左视频 + 右侧聊天面板(仅 chatVisible 时侧栏展开) -->
|
||||
<view class="body">
|
||||
<view ref="videoCol" class="video-col">
|
||||
<VideoGrid :tiles="gridTiles">
|
||||
<view ref="videoCol" class="video-col" :class="{ 'has-screen': !!screenTile }">
|
||||
<!-- Phase 3:有屏幕共享时,屏幕画面独占大画面区,参与者网格降级为底部横向条带 -->
|
||||
<view v-if="screenTile" class="screen-stage">
|
||||
<view class="screen-stage-tile">
|
||||
<VideoTile
|
||||
:user-id="screenTile.userId"
|
||||
:name="screenTile.name + '(屏幕共享)'"
|
||||
:is-local="screenTile.isLocal"
|
||||
:is-host="screenTile.isHost"
|
||||
:audio-enabled="false"
|
||||
:video-enabled="true"
|
||||
:audio-track="null"
|
||||
:video-track="screenTile.videoTrack"
|
||||
:is-speaking="false"
|
||||
/>
|
||||
</view>
|
||||
<view class="screen-strip">
|
||||
<view v-for="t in stripTiles" :key="t.key" class="strip-cell">
|
||||
<VideoTile
|
||||
:user-id="t.userId"
|
||||
:name="t.name"
|
||||
:is-local="t.isLocal"
|
||||
:is-host="t.isHost"
|
||||
:audio-enabled="t.audioEnabled"
|
||||
:video-enabled="t.videoEnabled"
|
||||
:audio-track="t.audioTrack"
|
||||
:video-track="t.videoTrack"
|
||||
:is-speaking="!!speakingMap[t.userId]"
|
||||
:compact="true"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<VideoGrid v-else :tiles="gridTiles">
|
||||
<template #tile="{ tile }">
|
||||
<VideoTile
|
||||
:user-id="tile.userId"
|
||||
@@ -94,8 +137,17 @@
|
||||
:unread-chat-count="unreadChatCount"
|
||||
:allow-chat="allowChat"
|
||||
:all-muted="allMuted"
|
||||
:screen-sharing="screenSharingByMe"
|
||||
:screen-loading="screenLoading"
|
||||
:screen-disabled="screenDisabled"
|
||||
:screen-disabled-reason="screenDisabledReason"
|
||||
:show-recording="isHost"
|
||||
:recording="isRecording"
|
||||
:recording-loading="recordingLoading"
|
||||
@mic-toggle="onMicToggle"
|
||||
@cam-toggle="onCamToggle"
|
||||
@screen-toggle="onScreenToggle"
|
||||
@recording-toggle="onRecordingToggle"
|
||||
@invite="openInvite"
|
||||
@members="openMembers"
|
||||
@chat="openChat"
|
||||
@@ -157,7 +209,12 @@ import {
|
||||
MEETING_LOCAL_STATE_RECONNECTING,
|
||||
MEETING_LOCAL_STATE_LEAVING,
|
||||
MEETING_LOCAL_STATE_ENDED,
|
||||
MEETING_ENDED_REASON_LABEL
|
||||
MEETING_ENDED_REASON_LABEL,
|
||||
MEETING_RECORDING_STATUS_RECORDING,
|
||||
MEETING_RECORDING_STATUS_UPLOADING,
|
||||
MEETING_RECORDING_STATUS_READY,
|
||||
MEETING_RECORDING_STATUS_FAILED,
|
||||
MEETING_RECORDING_STATUS_LABEL
|
||||
} from '@/constants/meeting'
|
||||
|
||||
import VideoGrid from '@/components/meeting/VideoGrid.vue'
|
||||
@@ -178,6 +235,7 @@ const chatVisible = ref(false)
|
||||
const leaveDialogVisible = ref(false)
|
||||
const audioLoading = ref(false)
|
||||
const videoLoading = ref(false)
|
||||
const screenLoading = ref(false)
|
||||
const chatLoadingMore = ref(false)
|
||||
const networkLevel = ref(4) // 持续接入 getStats 后动态更新(Task 16 进一步收敛)
|
||||
const startedAt = ref(0)
|
||||
@@ -289,18 +347,94 @@ const durationText = computed(() => {
|
||||
const audioEnabled = computed(() => meetingStore.localAudioEnabled)
|
||||
const videoEnabled = computed(() => meetingStore.localVideoEnabled)
|
||||
|
||||
/** 监听 store.getLocalTrack 的变化:localProducers 变化即 bump 版本 */
|
||||
const localTrackVersion = computed(() => {
|
||||
return `${meetingStore.localProducers.audio || ''}-${meetingStore.localProducers.video || ''}`
|
||||
// ============ Phase 3 屏幕共享 ============
|
||||
|
||||
/** 当前会议是否已有屏幕共享(任意成员) */
|
||||
const screenSharingActive = computed(() => !!meetingStore.screenShare?.ownerUserId)
|
||||
/** 屏幕共享者是否就是自己(决定工具栏按钮高亮 + 文案) */
|
||||
const screenSharingByMe = computed(() => {
|
||||
const owner = meetingStore.screenShare?.ownerUserId
|
||||
return !!owner && owner === myUserId.value
|
||||
})
|
||||
/** H5 桌面浏览器才有 getDisplayMedia;非 H5 / 移动端 / 老内核都禁用按钮 */
|
||||
const screenSupported = computed(() => {
|
||||
// #ifdef H5
|
||||
return typeof navigator !== 'undefined' &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.getDisplayMedia === 'function'
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return false
|
||||
// #endif
|
||||
})
|
||||
/** 按钮禁用条件:平台不支持 / 已有他人在共享 */
|
||||
const screenDisabled = computed(() => {
|
||||
if (!screenSupported.value) return true
|
||||
if (screenSharingActive.value && !screenSharingByMe.value) return true
|
||||
return false
|
||||
})
|
||||
const screenDisabledReason = computed(() => {
|
||||
if (!screenSupported.value) return '当前浏览器不支持屏幕共享'
|
||||
if (screenSharingActive.value && !screenSharingByMe.value) return '已有成员正在共享屏幕'
|
||||
return ''
|
||||
})
|
||||
|
||||
/** 远端 track 版本:让 tiles 在 slot.audio/slot.video 变化时重新计算 */
|
||||
/**
|
||||
* 屏幕共享大画面 tile:当前会议有屏幕共享时返回 tile 描述对象,否则 null
|
||||
*
|
||||
* track 来源分两路:
|
||||
* - 自己共享:从 store.getLocalTrack('screen') 取本地 track
|
||||
* - 他人共享:从 store.getRemoteTrack(ownerUid, 'screen') 取远端 Consumer track
|
||||
*
|
||||
* 触发重算:依赖 screenShare reactive + localTrackVersion + remoteTrackVersion
|
||||
*/
|
||||
const screenTile = computed(() => {
|
||||
const owner = meetingStore.screenShare?.ownerUserId
|
||||
if (!owner) return null
|
||||
// 触发依赖收集,让 store 内 producer/consumer 变化也能让本 computed 重算
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
localTrackVersion.value
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
remoteTrackVersion.value
|
||||
|
||||
const isLocal = owner === myUserId.value
|
||||
const track = isLocal
|
||||
? meetingStore.getLocalTrack('screen')
|
||||
: meetingStore.getRemoteTrack(owner, 'screen')
|
||||
// track 还没就绪时(screen.started 已到但 producer.new 未到 / consumer 未建好)暂不显示大画面,
|
||||
// 等下次重算(_onProducerNew 完成后 remoteTrackVersion 变化触发)
|
||||
if (!track) return null
|
||||
|
||||
return {
|
||||
key: `screen_${owner}`,
|
||||
userId: owner,
|
||||
name: isLocal ? '你' : nameOf(owner),
|
||||
isLocal,
|
||||
isHost: owner === hostId.value,
|
||||
videoTrack: track
|
||||
}
|
||||
})
|
||||
|
||||
/** 屏幕共享期间,底部条带显示参会者(含本地 camera tile) */
|
||||
const stripTiles = computed(() => {
|
||||
if (!screenTile.value) return []
|
||||
// 直接复用 tiles 列表(含本地);后续可按需限制数量
|
||||
return tiles.value
|
||||
})
|
||||
|
||||
/** 监听 store.getLocalTrack 的变化:localProducers 变化即 bump 版本(含屏幕共享 producer) */
|
||||
const localTrackVersion = computed(() => {
|
||||
const lp = meetingStore.localProducers
|
||||
return `${lp.audio || ''}-${lp.video || ''}-${lp.screen || ''}`
|
||||
})
|
||||
|
||||
/** 远端 track 版本:让 tiles 在 slot.audio/slot.video/slot.screen 变化时重新计算 */
|
||||
const remoteTrackVersion = computed(() => {
|
||||
const parts = []
|
||||
const map = meetingStore.remoteConsumers
|
||||
Object.keys(map).forEach((uid) => {
|
||||
const slot = map[uid]
|
||||
parts.push(`${uid}:${slot?.audio?.id || ''}:${slot?.video?.id || ''}`)
|
||||
parts.push(`${uid}:${slot?.audio?.id || ''}:${slot?.video?.id || ''}:${slot?.screen?.id || ''}`)
|
||||
})
|
||||
return parts.join('|')
|
||||
})
|
||||
@@ -439,6 +573,76 @@ const onCamToggle = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具栏共享屏幕切换
|
||||
*
|
||||
* 状态机:
|
||||
* - 未在共享 + 平台支持 + 无他人共享 → 调 startScreenShare
|
||||
* - 自己已在共享 → 调 stopScreenShare
|
||||
* - 其他场景由 MeetingToolbar.emitIf 拦截,不会进入本函数
|
||||
*
|
||||
* 容错:getDisplayMedia 用户取消会抛 NotAllowedError,对用户体验友好的提示是"已取消"
|
||||
*/
|
||||
const onScreenToggle = async () => {
|
||||
if (screenLoading.value) return
|
||||
screenLoading.value = true
|
||||
try {
|
||||
if (screenSharingByMe.value) {
|
||||
await meetingStore.stopScreenShare()
|
||||
} else {
|
||||
await meetingStore.startScreenShare()
|
||||
}
|
||||
} catch (err) {
|
||||
// NotAllowedError = 用户在系统选择器中点了取消,静默处理;其他错误才提示
|
||||
if (err?.name !== 'NotAllowedError') {
|
||||
uni.showToast({ title: err?.message || '屏幕共享失败', icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
screenLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 录制(Phase B) ============
|
||||
|
||||
/** 当前是否处于"录制中"(按钮高亮 + 顶部红点) */
|
||||
const isRecording = computed(() => meetingStore.recording.status === MEETING_RECORDING_STATUS_RECORDING)
|
||||
/** 录制按钮 loading:REST 调用期间或后端异步上传期间均禁用 */
|
||||
const recordingLoading = computed(() => meetingStore.recording.pending || meetingStore.recording.status === MEETING_RECORDING_STATUS_UPLOADING)
|
||||
/** 录制中提示文案(VideoGrid 上方红点 hover/aria) */
|
||||
const recordingHintText = computed(() => {
|
||||
const r = meetingStore.recording
|
||||
if (!r.status) return ''
|
||||
return MEETING_RECORDING_STATUS_LABEL[r.status] || r.status
|
||||
})
|
||||
|
||||
/** 切换录制:仅 host;按钮 emitIf 已防抖,这里再做 try/catch + toast */
|
||||
const onRecordingToggle = async () => {
|
||||
if (!isHost.value) return
|
||||
if (recordingLoading.value) return
|
||||
try {
|
||||
if (isRecording.value) {
|
||||
await meetingStore.stopRecording()
|
||||
uni.showToast({ title: '录制已停止,正在上传…', icon: 'none' })
|
||||
} else {
|
||||
await meetingStore.startRecording()
|
||||
uni.showToast({ title: '已开始录制', icon: 'success' })
|
||||
}
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err?.message || '录制操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听录制终态:ready → 提示已就绪;failed → 错误提示 */
|
||||
watch(() => meetingStore.recording.status, (newStatus, oldStatus) => {
|
||||
if (newStatus === oldStatus) return
|
||||
if (newStatus === MEETING_RECORDING_STATUS_READY) {
|
||||
uni.showToast({ title: '录制已就绪', icon: 'success' })
|
||||
} else if (newStatus === MEETING_RECORDING_STATUS_FAILED) {
|
||||
const reason = meetingStore.recording.failureReason || '未知原因'
|
||||
uni.showToast({ title: `录制失败:${reason}`, icon: 'none', duration: 3000 })
|
||||
}
|
||||
})
|
||||
|
||||
const openInvite = () => { inviteVisible.value = !inviteVisible.value }
|
||||
const openMembers = () => { memberVisible.value = !memberVisible.value }
|
||||
const openChat = () => {
|
||||
@@ -733,6 +937,40 @@ onUnload(() => {
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
/* Phase B 录制状态指示器(顶栏) */
|
||||
.recording-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border: 1rpx solid rgba(239, 68, 68, 0.55);
|
||||
color: #FCA5A5;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.recording-indicator.uploading {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
border-color: rgba(245, 158, 11, 0.55);
|
||||
color: #FCD34D;
|
||||
}
|
||||
.rec-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: #EF4444;
|
||||
animation: rec-blink 1s infinite ease-in-out;
|
||||
}
|
||||
.recording-indicator.uploading .rec-dot {
|
||||
background: #F59E0B;
|
||||
}
|
||||
@keyframes rec-blink {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.7); }
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -748,6 +986,46 @@ onUnload(() => {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Phase 3 屏幕共享专属布局:大画面 + 底部横向条带 */
|
||||
.video-col.has-screen { flex-direction: column; }
|
||||
.screen-stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
padding: 12rpx;
|
||||
}
|
||||
.screen-stage-tile {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #000;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
/* 屏幕画面用 contain 而非 cover,避免桌面边缘内容被裁切 */
|
||||
}
|
||||
.screen-stage-tile :deep(video) {
|
||||
object-fit: contain !important;
|
||||
background: #000;
|
||||
}
|
||||
.screen-strip {
|
||||
flex-shrink: 0;
|
||||
height: 160rpx;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12rpx;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.screen-strip .strip-cell {
|
||||
flex: 0 0 220rpx;
|
||||
height: 100%;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.chat-col {
|
||||
width: 0;
|
||||
min-height: 0;
|
||||
|
||||
Reference in New Issue
Block a user