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:
bujinyuan
2026-04-23 16:04:46 +08:00
parent 46b37b8b4c
commit 790996feaf
26 changed files with 2043 additions and 151 deletions

View File

@@ -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 previewStreamtrack 所有权随之转给 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
}

View File

@@ -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({