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
}