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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user