diff --git a/frontend/src/pages/cloud-law/video-call.vue b/frontend/src/pages/cloud-law/video-call.vue index 95c875e..f772d03 100644 --- a/frontend/src/pages/cloud-law/video-call.vue +++ b/frontend/src/pages/cloud-law/video-call.vue @@ -71,25 +71,50 @@ {{ durationText }} - - - + + + + + + 摄像头{{ videoEnabled ? '已开' : '已关' }} + + + + + + + 扬声器{{ speakerMuted ? '已关' : '已开' }} + + + + + + + 麦克风{{ audioEnabled ? '已开' : '已关' }} - 摄像头{{ videoEnabled ? '已开' : '已关' }} - - - + + + + + + 截屏 - 挂断 - - - - + + + + + 挂断 + + + + + + + 镜头翻转 - 麦克风{{ audioEnabled ? '已开' : '已关' }} @@ -118,11 +143,15 @@ import voiceOnIcon from '@/static/vocie_calling_1.png' import voiceOffIcon from '@/static/vocie_stop.png' import videoOnIcon from '@/static/vodie_calling_1.png' import videoOffIcon from '@/static/vodie_stop.png' +import speakerOnIcon from '@/static/laba_open.png' +import speakerOffIcon from '@/static/laba_stop.png' const meetingStore = useMeetingStore() const userStore = useUserStore() const rawCode = ref('') +const callId = ref('') +const cloudLawApiBase = ref('') const callMode = ref('video') const joining = ref(false) const joinError = ref('') @@ -136,6 +165,10 @@ const preferSelfMain = ref(false) let timerHandle = null let stateStopWatch = null let videoRetryHandle = null +let commandPollHandle = null +let commandPolling = false +const speakerMuted = ref(false) +const cameraFacing = ref('user') const digitsOf = (value) => String(value || '').replace(/\D/g, '').slice(0, 9) const sameUser = (a, b) => String(a || '') === String(b || '') @@ -155,6 +188,7 @@ const speakingMap = computed(() => meetingStore.speakingMap || {}) const isVoiceMode = computed(() => callMode.value === 'voice') const cameraIcon = computed(() => videoEnabled.value ? videoOnIcon : videoOffIcon) const micIcon = computed(() => audioEnabled.value ? voiceOnIcon : voiceOffIcon) +const speakerIcon = computed(() => speakerMuted.value ? speakerOffIcon : speakerOnIcon) const entryInitial = computed(() => { const name = userStore.userInfo?.nickname || userStore.userInfo?.username || '在' @@ -319,6 +353,152 @@ const startTimer = () => { const localMediaError = ref('') +const resolveCloudLawApiUrl = (path) => { + const base = String(cloudLawApiBase.value || '').replace(/\/+$/, '') + return `${base}${path}` +} + +const requestCloudLawCommands = (url) => { + return new Promise((resolve, reject) => { + uni.request({ + url, + method: 'GET', + timeout: 8000, + success: (res) => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(res.data || {}) + } else { + reject(res.data || { message: `HTTP ${res.statusCode}` }) + } + }, + fail: reject + }) + }) +} + +const pollCommandsOnce = async () => { + if (!callId.value || !meetingStore.isInMeeting || leaving.value || ended.value || commandPolling) return + commandPolling = true + try { + const room = meetingStore.currentRoom?.room_code || formattedCode.value || '' + const path = `/api/applet/voiceConnection/publicCommands/${encodeURIComponent(callId.value)}${room ? `?roomCode=${encodeURIComponent(room)}` : ''}` + const resp = await requestCloudLawCommands(resolveCloudLawApiUrl(path)) + const payload = resp?.data || resp || {} + const commands = payload.commands || payload.data?.commands || [] + for (const command of commands) { + await executeCloudLawCommand(command) + } + } catch (err) { + console.warn('[CloudLawVideoCall] poll commands failed', err) + } finally { + commandPolling = false + } +} + +const startCommandPolling = () => { + if (!callId.value || commandPollHandle) return + pollCommandsOnce() + commandPollHandle = setInterval(pollCommandsOnce, 1200) +} + +const stopCommandPolling = () => { + if (commandPollHandle) { + clearInterval(commandPollHandle) + commandPollHandle = null + } +} + +const setSpeakerMuted = (muted) => { + speakerMuted.value = muted + // #ifdef H5 + try { + const audioEls = document.querySelectorAll('.cloud-law-call audio') + audioEls.forEach(el => { + el.muted = muted + el.volume = muted ? 0 : 1 + }) + } catch (err) { + console.warn('[CloudLawVideoCall] set speaker failed', err) + } + // #endif +} + +const toggleSpeaker = () => { + setSpeakerMuted(!speakerMuted.value) +} + +const takeSnapshot = () => { + // #ifdef H5 + try { + const video = document.querySelector('.cloud-law-call .main-video video') || document.querySelector('.cloud-law-call video') + if (!video || !video.videoWidth || !video.videoHeight) { + uni.showToast({ title: '当前无视频画面可截屏', icon: 'none' }) + return + } + const canvas = document.createElement('canvas') + canvas.width = video.videoWidth + canvas.height = video.videoHeight + const ctx = canvas.getContext('2d') + ctx.drawImage(video, 0, 0, canvas.width, canvas.height) + const a = document.createElement('a') + a.href = canvas.toDataURL('image/png') + a.download = `cloud-law-video-${Date.now()}.png` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + uni.showToast({ title: '截屏已保存', icon: 'success' }) + } catch (err) { + console.warn('[CloudLawVideoCall] snapshot failed', err) + uni.showToast({ title: '截屏失败', icon: 'none' }) + } + // #endif +} + +const flipCameraByCommand = async (value) => { + const nextFacing = value === 'front' || value === 'user' + ? 'user' + : value === 'back' || value === 'environment' + ? 'environment' + : (cameraFacing.value === 'user' ? 'environment' : 'user') + cameraFacing.value = nextFacing + try { + if (meetingStore.localProducers?.video) { + await meetingStore.switchLocalCameraFacing(nextFacing) + } else { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: nextFacing } + }) + const track = stream.getVideoTracks()[0] + await meetingStore.startLocalVideo(undefined, track) + } + localMediaError.value = '' + } catch (err) { + console.warn('[CloudLawVideoCall] flip camera failed', err) + localMediaError.value = `镜头翻转:${err?.message || err}` + } +} + +const flipCamera = () => { + flipCameraByCommand() +} + +const executeCloudLawCommand = async (cmd = {}) => { + const command = String(cmd.command || cmd.type || '').toLowerCase() + const value = cmd.value + if (command === 'speaker') { + setSpeakerMuted(value === 'off' || value === false || value === 'false' || value === '0') + return + } + if (command === 'screenshot') { + takeSnapshot() + return + } + if (command === 'flipcamera' || command === 'flip_camera') { + await flipCameraByCommand(value) + } +} + const joinMeeting = async () => { if (joining.value) return @@ -357,6 +537,7 @@ const joinMeeting = async () => { await meetingStore.joinAndEnter(code, '', prefs) startTimer() scheduleVideoRetry() + startCommandPolling() } catch (err) { joinError.value = err?.message || '视频咨询接入失败' console.error('[CloudLawVideoCall] join failed', err) @@ -446,11 +627,11 @@ const acquireAudioTrack = async () => { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }) return stream.getAudioTracks()[0] } -const acquireVideoTrack = async () => { +const acquireVideoTrack = async (facingMode = 'user') => { if (!navigator?.mediaDevices?.getUserMedia) throw new Error('当前环境不支持 getUserMedia') const stream = await navigator.mediaDevices.getUserMedia({ audio: false, - video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' } + video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode } }) return stream.getVideoTracks()[0] } @@ -483,7 +664,7 @@ const onCamToggle = async () => { if (meetingStore.localProducers?.video) { await meetingStore.setLocalVideoMuted(videoEnabled.value) } else { - const track = await acquireVideoTrack() + const track = await acquireVideoTrack(cameraFacing.value) await meetingStore.startLocalVideo(undefined, track) } localMediaError.value = '' @@ -498,6 +679,7 @@ const onCamToggle = async () => { const hangup = async () => { if (leaving.value) return leaving.value = true + stopCommandPolling() try { // 云律视频咨询:发起人(小程序端用户)在 CreateRoomForInternal 时被加成 host, // 用户挂机 = 咨询结束,无条件 endMeeting 让律师 iframe 收到 room.ended 自动退出。 @@ -522,6 +704,8 @@ const hangup = async () => { onLoad(query => { rawCode.value = query?.code || query?.roomCode || '' + callId.value = query?.callId || query?.call_id || '' + cloudLawApiBase.value = query?.cloudLawApiBase || query?.cloud_law_api_base || '' const mode = String(query?.mode || query?.callType || query?.type || '').toLowerCase() callMode.value = mode === 'voice' || mode === 'audio' ? 'voice' : 'video' joinMeeting() @@ -532,8 +716,10 @@ onMounted(() => { if (s === MEETING_LOCAL_STATE_CONNECTED) { startTimer() scheduleVideoRetry() + startCommandPolling() } if (s === MEETING_LOCAL_STATE_ENDED) { + stopCommandPolling() const reason = meetingStore.lastEndedReason const label = MEETING_ENDED_REASON_LABEL?.[reason] || '视频咨询已结束' uni.showToast({ title: label, icon: 'none' }) @@ -555,9 +741,11 @@ onBeforeUnmount(() => { clearTimeout(videoRetryHandle) videoRetryHandle = null } + stopCommandPolling() }) onUnload(() => { + stopCommandPolling() if (meetingStore.isInMeeting && meetingStore.localState !== MEETING_LOCAL_STATE_LEAVING) { meetingStore.leave().catch(() => {}) } @@ -795,24 +983,26 @@ onUnload(() => { } .video-controls { padding: 0 58rpx; - display: grid; - grid-template-columns: repeat(3, 1fr); - align-items: end; - column-gap: 44rpx; + display: flex; + flex-direction: column; + gap: 30rpx; background: transparent; } .voice-controls { padding: 0 58rpx; + display: flex; + flex-direction: column; + gap: 30rpx; + background: transparent; +} +.controls-row { display: grid; grid-template-columns: repeat(3, 1fr); align-items: end; column-gap: 44rpx; - background: transparent; } -.voice-control-col { - display: flex; - flex-direction: column; - gap: 36rpx; +.controls-row-bottom { + align-items: end; } .control-item { display: flex; @@ -858,6 +1048,15 @@ onUnload(() => { width: 150rpx; height: 150rpx; } +.text-icon-btn { + color: #ffffff; +} +.text-icon { + font-size: 54rpx; + line-height: 1; + color: #ffffff; + font-weight: 700; +} .hangup-image { width: 150rpx; height: 150rpx; diff --git a/frontend/src/store/meeting.js b/frontend/src/store/meeting.js index 0e1ff61..5e80772 100644 --- a/frontend/src/store/meeting.js +++ b/frontend/src/store/meeting.js @@ -1494,6 +1494,43 @@ export const useMeetingStore = defineStore('meeting', () => { return producer ? producer.track : null } + const switchLocalCameraFacing = async (facingMode = 'user') => { + if (!_engine || !localProducers.video) return false + if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + throw new Error('当前环境不支持切换摄像头') + } + const oldProducerId = localProducers.video + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + width: { ideal: 640 }, + height: { ideal: 480 }, + frameRate: { ideal: 15, max: 24 }, + facingMode + } + }) + const track = stream.getVideoTracks()[0] + if (!track) { + try { stream.getTracks().forEach(t => t.stop()) } catch {} + throw new Error('未获取到摄像头画面') + } + let newProducer = null + try { + newProducer = await _engine.produce({ kind: 'video', track }) + localProducers.video = newProducer.id + localVideoEnabled.value = true + try { await _engine.closeProducer(oldProducerId) } catch (err) { _log('warn', '[Meeting] 切换摄像头时关闭旧 Producer 失败', err) } + _broadcastSelfState({ video_enabled: true }) + return true + } catch (err) { + try { track.stop() } catch {} + if (newProducer && newProducer.id) { + try { await _engine.closeProducer(newProducer.id) } catch {} + } + throw err + } + } + // ==================== Actions:聊天/管理 ==================== /** @@ -1649,6 +1686,7 @@ export const useMeetingStore = defineStore('meeting', () => { stopLocalVideo, setLocalAudioMuted, setLocalVideoMuted, + switchLocalCameraFacing, startScreenShare, stopScreenShare, recording,