feat(meeting): Task 15 UI 打磨 + 主持人四件套 + 媒体层稳定性回归修复
- Task 15 UI:6 项原创特色(说话者流光 / 柔性网格 / 自视频浮窗 / 静音氛围色 / 入会滑入 / NetworkBadge 3 条波浪)+ 说话者双源探测(RTP audioLevel + WebAudio RMS)+ 主持人四件套(静音/开麦/转让/踢出) - 新增 SelfVideoFloat 浮窗组件 + 4 份 design-system 页面文档(home / preview / room / invite) - 媒体回归补丁(手工联调触发): * 后端 signal_service 在 OnRoomJoin 追加 pushExistingRoomState → 向新加入者补发历史 producers + 历史成员 audio/video 状态 * 新增 Redis Hash memberStateKey 持久化成员 audio/video enabled,OnMemberStateChanged 落盘、cleanupUserResources 清理 * 前端 _broadcastSelfState 在本地音视频开关末尾同步 state.changed;_afterJoined 先 getRoom 再 room.join 修复后入者成员状态时序;_onMemberStateChanged 占位兜底 * _cleanupRemoteProducer 改为按 producerId 精准清理(修复 "关音频误关视频") * _onRoomEnded + createAndEnter/joinAndEnter 强化 reset(修复重入会看不到自己画面) * mediasoup-client ensureSend/RecvTransport 引入 in-flight Promise 锁防并发重复建连 - 文档同步:CURRENT_STATUS.md / Task 15 plan / phase2e-2 implementation plan Made-with: Cursor
This commit is contained in:
@@ -188,7 +188,13 @@ const stopAudioMeter = () => {
|
||||
const startPreview = async () => {
|
||||
// #ifdef H5
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
permissionError.value = '当前浏览器不支持音视频预览(需 HTTPS + WebRTC)'
|
||||
// secure context 检查:大多数浏览器只在 HTTPS / localhost 下暴露 mediaDevices
|
||||
if (typeof window !== 'undefined' && window.isSecureContext === false) {
|
||||
const host = window.location?.host || ''
|
||||
permissionError.value = `当前为不安全连接,浏览器禁止访问摄像头/麦克风。请改用 HTTPS 访问:https://${host}(自签证书首次访问选择"仍然继续"即可)`
|
||||
} else {
|
||||
permissionError.value = '当前浏览器不支持音视频预览(需 HTTPS + WebRTC)'
|
||||
}
|
||||
return
|
||||
}
|
||||
stopPreviewStream()
|
||||
@@ -339,15 +345,33 @@ const onJoin = async () => {
|
||||
startVideo: mediaPrefs.startVideo
|
||||
}
|
||||
|
||||
// 释放预览流,避免与 mediasoup produce 争用摄像头(浏览器单轨互斥)
|
||||
stopPreviewStream()
|
||||
// iOS Safari/Chrome 上 preview 释放摄像头后 room 再次 getUserMedia 常触发 NotReadableError,
|
||||
// 改为把预览页已申请的 track 直接复用给 mediasoup produce。
|
||||
// 此处只停 AudioContext 分析器,不 stop previewStream,track 所有权随之转给 MediaEngine.produce。
|
||||
stopAudioMeter()
|
||||
|
||||
const rawAudioTrack = previewStream ? previewStream.getAudioTracks()[0] || null : null
|
||||
const rawVideoTrack = previewStream ? previewStream.getVideoTracks()[0] || null : null
|
||||
// 若用户关掉了"默认开麦/默认开摄像头",对应 track 不会被 mediasoup 接手,立即 stop 避免硬件泄漏
|
||||
if (rawAudioTrack && !mediaPrefs.startAudio) {
|
||||
try { rawAudioTrack.stop() } catch {}
|
||||
}
|
||||
if (rawVideoTrack && !mediaPrefs.startVideo) {
|
||||
try { rawVideoTrack.stop() } catch {}
|
||||
}
|
||||
const audioTrack = mediaPrefs.startAudio ? rawAudioTrack : null
|
||||
const videoTrack = mediaPrefs.startVideo ? rawVideoTrack : null
|
||||
// previewStream 对象本身不再需要持有,tracks 已取出交给 MediaEngine;
|
||||
// 避免在后续 onUnload 中误调 stopPreviewStream 把 track 给 stop 掉。
|
||||
previewStream = null
|
||||
|
||||
const prefs = {
|
||||
startAudio: mediaPrefs.startAudio,
|
||||
startVideo: mediaPrefs.startVideo,
|
||||
audioDeviceId: selectedAudioId.value,
|
||||
videoDeviceId: selectedVideoId.value
|
||||
videoDeviceId: selectedVideoId.value,
|
||||
audioTrack,
|
||||
videoTrack
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -367,6 +391,9 @@ const onJoin = async () => {
|
||||
const msg = err?.message || JSON.stringify(err)
|
||||
uni.showToast({ title: `加入失败:${msg}`, icon: 'none', duration: 3500 })
|
||||
console.error('[Preview] 加入会议失败', err)
|
||||
// 入会失败:track 尚未转给 mediasoup 或刚拿到就失败,显式 stop 避免摄像头泄漏
|
||||
try { audioTrack && audioTrack.stop && audioTrack.stop() } catch {}
|
||||
try { videoTrack && videoTrack.stop && videoTrack.stop() } catch {}
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
|
||||
<!-- 主区:左视频 + 右侧聊天面板(仅 chatVisible 时侧栏展开) -->
|
||||
<view class="body">
|
||||
<view class="video-col">
|
||||
<VideoGrid :tiles="tiles">
|
||||
<view ref="videoCol" class="video-col">
|
||||
<VideoGrid :tiles="gridTiles">
|
||||
<template #tile="{ tile }">
|
||||
<VideoTile
|
||||
:user-id="tile.userId"
|
||||
@@ -48,9 +48,25 @@
|
||||
:video-enabled="tile.videoEnabled"
|
||||
:audio-track="tile.audioTrack"
|
||||
:video-track="tile.videoTrack"
|
||||
:is-speaking="!!speakingMap[tile.userId]"
|
||||
/>
|
||||
</template>
|
||||
</VideoGrid>
|
||||
|
||||
<!-- 自视频浮窗(Task 15):仅桌面端浮窗模式开启时显示;图钉切换后本地 tile 回到网格里 -->
|
||||
<SelfVideoFloat
|
||||
v-if="floatEnabled && selfTile"
|
||||
:user-id="selfTile.userId"
|
||||
:name="selfTile.name"
|
||||
:is-host="selfTile.isHost"
|
||||
:audio-enabled="selfTile.audioEnabled"
|
||||
:video-enabled="selfTile.videoEnabled"
|
||||
:audio-track="selfTile.audioTrack"
|
||||
:video-track="selfTile.videoTrack"
|
||||
:is-speaking="!!speakingMap[selfTile.userId]"
|
||||
:container="videoCol"
|
||||
@pin-click="togglePin"
|
||||
/>
|
||||
</view>
|
||||
<view class="chat-col" :class="{ open: chatVisible }">
|
||||
<ChatPanel
|
||||
@@ -77,6 +93,7 @@
|
||||
:member-count="tiles.length"
|
||||
:unread-chat-count="unreadChatCount"
|
||||
:allow-chat="allowChat"
|
||||
:all-muted="allMuted"
|
||||
@mic-toggle="onMicToggle"
|
||||
@cam-toggle="onCamToggle"
|
||||
@invite="openInvite"
|
||||
@@ -96,6 +113,7 @@
|
||||
@close="memberVisible = false"
|
||||
@kick="onKickMember"
|
||||
@transfer-host="onTransferHost"
|
||||
@mute-member="onMuteMember"
|
||||
/>
|
||||
|
||||
<!-- 邀请弹窗 -->
|
||||
@@ -149,6 +167,7 @@ import MemberPanel from '@/components/meeting/MemberPanel.vue'
|
||||
import InviteDialog from '@/components/meeting/InviteDialog.vue'
|
||||
import NetworkBadge from '@/components/meeting/NetworkBadge.vue'
|
||||
import ChatPanel from '@/components/meeting/ChatPanel.vue'
|
||||
import SelfVideoFloat from '@/components/meeting/SelfVideoFloat.vue'
|
||||
|
||||
const meetingStore = useMeetingStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -160,11 +179,20 @@ const leaveDialogVisible = ref(false)
|
||||
const audioLoading = ref(false)
|
||||
const videoLoading = ref(false)
|
||||
const chatLoadingMore = ref(false)
|
||||
const networkLevel = ref(4) // Task 15 接入真实 getStats 后动态更新
|
||||
const networkLevel = ref(4) // 持续接入 getStats 后动态更新(Task 16 进一步收敛)
|
||||
const startedAt = ref(0)
|
||||
const nowTs = ref(Date.now())
|
||||
let timerHandle = null
|
||||
|
||||
/** 视频区容器引用:传给 SelfVideoFloat 计算吸附边界 */
|
||||
const videoCol = ref(null)
|
||||
|
||||
/** 说话者映射由 meeting store 探测出(Task 15),浮窗和网格都订阅它来驱动流光动效 */
|
||||
const speakingMap = computed(() => meetingStore.speakingMap)
|
||||
|
||||
/** 全员静音标记,用于 Task 15 工具栏冷色氛围(允许单人时恒为 false) */
|
||||
const allMuted = computed(() => meetingStore.isAllMuted)
|
||||
|
||||
// ============ 基础映射 ============
|
||||
|
||||
const myUserId = computed(() => userStore.userInfo?.id || 0)
|
||||
@@ -307,23 +335,28 @@ const tiles = computed(() => {
|
||||
participants.value.forEach(p => {
|
||||
if (!p.is_active && p.is_active !== undefined) return
|
||||
const isLocal = p.user_id === myUid
|
||||
let audioTrack = null
|
||||
let videoTrack = null
|
||||
if (isLocal) {
|
||||
audioTrack = meetingStore.getLocalTrack('audio')
|
||||
videoTrack = meetingStore.getLocalTrack('video')
|
||||
} else {
|
||||
audioTrack = meetingStore.getRemoteTrack(p.user_id, 'audio')
|
||||
videoTrack = meetingStore.getRemoteTrack(p.user_id, 'video')
|
||||
}
|
||||
// Task 15 回归修复:tile 是否渲染 <video>/<audio> 以 track 实际存在为准
|
||||
// 参会者的 audio_enabled/video_enabled 仅作为 MemberPanel 状态图标色彩依据,
|
||||
// 不再决定 VideoTile 媒体元素的挂载;否则一旦 state 字段滞后就会屏蔽实际画面
|
||||
const tile = {
|
||||
key: `u_${p.user_id}`,
|
||||
userId: p.user_id,
|
||||
name: isLocal ? '你' : nameOf(p.user_id),
|
||||
isLocal,
|
||||
isHost: p.user_id === hostId.value,
|
||||
audioEnabled: isLocal ? audioEnabled.value : (p.audio_enabled !== false),
|
||||
videoEnabled: isLocal ? videoEnabled.value : (p.video_enabled !== false),
|
||||
audioTrack: null,
|
||||
videoTrack: null
|
||||
}
|
||||
if (isLocal) {
|
||||
tile.audioTrack = meetingStore.getLocalTrack('audio')
|
||||
tile.videoTrack = meetingStore.getLocalTrack('video')
|
||||
} else {
|
||||
tile.audioTrack = meetingStore.getRemoteTrack(p.user_id, 'audio')
|
||||
tile.videoTrack = meetingStore.getRemoteTrack(p.user_id, 'video')
|
||||
audioEnabled: isLocal ? audioEnabled.value : !!audioTrack,
|
||||
videoEnabled: isLocal ? videoEnabled.value : !!videoTrack,
|
||||
audioTrack,
|
||||
videoTrack
|
||||
}
|
||||
list.push(tile)
|
||||
})
|
||||
@@ -344,6 +377,36 @@ const tiles = computed(() => {
|
||||
return list
|
||||
})
|
||||
|
||||
// ============ Task 15 浮窗模式 ============
|
||||
|
||||
/**
|
||||
* 浮窗开关:从 store.uiPrefs 读取,默认桌面端开启浮窗
|
||||
* 点击图钉按钮时 togglePin 切换,本地 tile 会在浮窗 <-> 网格间迁移
|
||||
*/
|
||||
const floatEnabled = computed(() => meetingStore.uiPrefs?.selfVideoFloat !== false)
|
||||
|
||||
/**
|
||||
* 自视频 tile:浮窗模式下从 tiles 中剥离出来单独挂载
|
||||
* 若 floatEnabled=false 则返回 null,自视频仍保留在 Grid 里
|
||||
*/
|
||||
const selfTile = computed(() => {
|
||||
if (!floatEnabled.value) return null
|
||||
return tiles.value.find(t => t.isLocal) || null
|
||||
})
|
||||
|
||||
/**
|
||||
* 网格 tiles:浮窗开启时剔除本地 tile(只渲染远端),否则保持原始
|
||||
* 这样 VideoGrid 的 layout-1/2/3/N 档位判断基于远端人数,符合用户认知
|
||||
*/
|
||||
const gridTiles = computed(() => {
|
||||
if (!floatEnabled.value) return tiles.value
|
||||
return tiles.value.filter(t => !t.isLocal)
|
||||
})
|
||||
|
||||
const togglePin = () => {
|
||||
meetingStore.uiPrefs.selfVideoFloat = !meetingStore.uiPrefs.selfVideoFloat
|
||||
}
|
||||
|
||||
// ============ 事件处理 ============
|
||||
|
||||
const onMicToggle = async () => {
|
||||
@@ -439,6 +502,15 @@ const onKickMember = async (uid) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onMuteMember = async ({ userId: uid, mute }) => {
|
||||
try {
|
||||
await meetingStore.muteMember(uid, mute)
|
||||
uni.showToast({ title: mute ? '已请对方静音' : '已请对方开麦', icon: 'none' })
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const onTransferHost = async (uid) => {
|
||||
const confirmed = await new Promise(resolve => {
|
||||
uni.showModal({
|
||||
|
||||
Reference in New Issue
Block a user