diff --git a/frontend/src/pages/cloud-law/video-call.vue b/frontend/src/pages/cloud-law/video-call.vue index 4280dd9..6b0e314 100644 --- a/frontend/src/pages/cloud-law/video-call.vue +++ b/frontend/src/pages/cloud-law/video-call.vue @@ -467,26 +467,27 @@ const acquireVideoTrack = async () => { } const onMicToggle = async () => { - pushDebug(`mic点击 loading=${audioLoading.value} leaving=${leaving.value} on=${audioEnabled.value} producer=${meetingStore.localProducers?.audio || 'null'}`) + pushDebug(`mic点击 loading=${audioLoading.value} on=${audioEnabled.value} producer=${meetingStore.localProducers?.audio || 'null'}`) if (audioLoading.value || leaving.value) { pushDebug('mic 被 loading/leaving 拦住') return } audioLoading.value = true try { - if (audioEnabled.value) { - pushDebug('mic -> stopLocalAudio') - await meetingStore.stopLocalAudio() - pushDebug('mic stop 完成') + if (meetingStore.localProducers?.audio) { + // 已经有 producer:只 mute/unmute,不释放硬件(关键:WebView 释放后申请会被拒) + pushDebug(`mic -> setLocalAudioMuted(${audioEnabled.value})`) + await meetingStore.setLocalAudioMuted(audioEnabled.value) + pushDebug(`mic mute 切换完成 enabled=${meetingStore.localAudioEnabled}`) } else { + // 没 producer:兜底走完整流程,自己 getUserMedia 拿 track(用户手势作用域内) pushDebug('mic -> getUserMedia(audio)') const track = await acquireAudioTrack() pushDebug(`mic 拿到 track id=${track?.id?.slice(0, 8)} label=${track?.label || ''}`) - pushDebug('mic -> startLocalAudio(track)') await meetingStore.startLocalAudio(undefined, track) pushDebug(`mic start 完成 enabled=${meetingStore.localAudioEnabled}`) - localMediaError.value = '' } + localMediaError.value = '' } catch (err) { pushDebug(`mic 报错: ${err?.name || ''} ${err?.message || err}`) localMediaError.value = `麦克风:${err?.message || err}` @@ -496,26 +497,25 @@ const onMicToggle = async () => { } const onCamToggle = async () => { - pushDebug(`cam点击 loading=${videoLoading.value} leaving=${leaving.value} on=${videoEnabled.value} producer=${meetingStore.localProducers?.video || 'null'}`) + pushDebug(`cam点击 loading=${videoLoading.value} on=${videoEnabled.value} producer=${meetingStore.localProducers?.video || 'null'}`) if (videoLoading.value || leaving.value) { pushDebug('cam 被 loading/leaving 拦住') return } videoLoading.value = true try { - if (videoEnabled.value) { - pushDebug('cam -> stopLocalVideo') - await meetingStore.stopLocalVideo() - pushDebug('cam stop 完成') + if (meetingStore.localProducers?.video) { + pushDebug(`cam -> setLocalVideoMuted(${videoEnabled.value})`) + await meetingStore.setLocalVideoMuted(videoEnabled.value) + pushDebug(`cam mute 切换完成 enabled=${meetingStore.localVideoEnabled}`) } else { pushDebug('cam -> getUserMedia(video)') const track = await acquireVideoTrack() pushDebug(`cam 拿到 track id=${track?.id?.slice(0, 8)} label=${track?.label || ''}`) - pushDebug('cam -> startLocalVideo(track)') await meetingStore.startLocalVideo(undefined, track) pushDebug(`cam start 完成 enabled=${meetingStore.localVideoEnabled}`) - localMediaError.value = '' } + localMediaError.value = '' } catch (err) { pushDebug(`cam 报错: ${err?.name || ''} ${err?.message || err}`) localMediaError.value = `摄像头:${err?.message || err}` diff --git a/frontend/src/store/meeting.js b/frontend/src/store/meeting.js index 75f6363..0e1ff61 100644 --- a/frontend/src/store/meeting.js +++ b/frontend/src/store/meeting.js @@ -1217,6 +1217,36 @@ export const useMeetingStore = defineStore('meeting', () => { _broadcastSelfState({ video_enabled: true }) } + /** + * 静音/解除静音本地音频(不释放硬件) + * 用于"点击麦克风按钮关/开"场景:避免在 iOS Safari / 微信 WebView 上释放硬件后再申请被拒。 + * 与 stopLocalAudio 区别:本方法只暂停 Producer,track 仍持有硬件,可幂等多次切换。 + */ + const setLocalAudioMuted = async (muted) => { + if (!_engine || !localProducers.audio) return false + const ok = muted + ? _engine.pauseProducer(localProducers.audio) + : _engine.resumeProducer(localProducers.audio) + if (ok) { + localAudioEnabled.value = !muted + _broadcastSelfState({ audio_enabled: !muted }) + } + return ok + } + + /** 静音/解除静音本地视频(不释放硬件),用法同 setLocalAudioMuted */ + const setLocalVideoMuted = async (muted) => { + if (!_engine || !localProducers.video) return false + const ok = muted + ? _engine.pauseProducer(localProducers.video) + : _engine.resumeProducer(localProducers.video) + if (ok) { + localVideoEnabled.value = !muted + _broadcastSelfState({ video_enabled: !muted }) + } + return ok + } + /** 关闭本地音频 */ const stopLocalAudio = async () => { if (!_engine || !localProducers.audio) return @@ -1617,6 +1647,8 @@ export const useMeetingStore = defineStore('meeting', () => { startLocalVideo, stopLocalAudio, stopLocalVideo, + setLocalAudioMuted, + setLocalVideoMuted, startScreenShare, stopScreenShare, recording, diff --git a/frontend/src/utils/mediasoup-client.js b/frontend/src/utils/mediasoup-client.js index 60b9edb..1ab669d 100644 --- a/frontend/src/utils/mediasoup-client.js +++ b/frontend/src/utils/mediasoup-client.js @@ -405,6 +405,39 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa } } + /** + * 暂停指定 Producer:mediasoup-client 内部会把 track.enabled=false, + * 远端 Consumer 收到的就是静音/黑帧,但本地 track 仍持有硬件不释放。 + * 用于"关摄像头/麦克风"按钮:避免在 iOS Safari / 微信 WebView 上释放硬件后再申请被拒(NotAllowedError)。 + */ + const pauseProducer = (producerId) => { + const producer = producers.get(producerId) + if (!producer || producer.closed) return false + try { + if (!producer.paused) producer.pause() + // 双保险:有些 mediasoup-client 版本不会自动改 track.enabled + if (producer.track) producer.track.enabled = false + return true + } catch (e) { + logger('warn', '[MediaEngine] producer.pause 抛错', e) + return false + } + } + + /** 恢复 Producer:与 pauseProducer 对应 */ + const resumeProducer = (producerId) => { + const producer = producers.get(producerId) + if (!producer || producer.closed) return false + try { + if (producer.paused) producer.resume() + if (producer.track) producer.track.enabled = true + return true + } catch (e) { + logger('warn', '[MediaEngine] producer.resume 抛错', e) + return false + } + } + /** 本地 Consumer 关闭(通常由 producer.new closed=true 广播触发,无需额外 WS) */ const closeConsumer = (consumerId) => { const consumer = consumers.get(consumerId) @@ -445,6 +478,8 @@ export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defa consume, resumeConsumer, closeProducer, + pauseProducer, + resumeProducer, closeConsumer, close, getDevice: () => device,