diff --git a/frontend/src/components/meeting/VideoTile.vue b/frontend/src/components/meeting/VideoTile.vue index 36a46c6..68a86c4 100644 --- a/frontend/src/components/meeting/VideoTile.vue +++ b/frontend/src/components/meeting/VideoTile.vue @@ -84,6 +84,7 @@ const ensureMediaEl = (tagName) => { el.style.height = '100%' el.style.objectFit = 'cover' el.style.background = '#0B1220' + el.style.display = 'block' } parent.appendChild(el) } @@ -123,6 +124,10 @@ const applyVideo = () => { if (el) { el.muted = props.isLocal el.srcObject = new MediaStream([props.videoTrack]) + const playPromise = el.play && el.play() + if (playPromise && typeof playPromise.catch === 'function') { + playPromise.catch(() => {}) + } } } else { removeMediaEl('video') @@ -137,6 +142,10 @@ const applyAudio = () => { const el = ensureMediaEl('audio') if (el) { el.srcObject = new MediaStream([props.audioTrack]) + const playPromise = el.play && el.play() + if (playPromise && typeof playPromise.catch === 'function') { + playPromise.catch(() => {}) + } } } else { removeMediaEl('audio') diff --git a/frontend/src/pages/cloud-law/video-call.vue b/frontend/src/pages/cloud-law/video-call.vue index 41f5c02..50acd0d 100644 --- a/frontend/src/pages/cloud-law/video-call.vue +++ b/frontend/src/pages/cloud-law/video-call.vue @@ -66,50 +66,14 @@ {{ durationText }} - - - - - - 麦克风{{ audioEnabled ? '已开' : '已关' }} - - - - - - - 扬声器已开 - - + - + 摄像头{{ videoEnabled ? '已开' : '已关' }} - - - - - - - - - - - - - - - - - - - 摄像头已关 - - - @@ -117,18 +81,11 @@ 挂断 - - - - - - - - - - - 麦克风{{ audioEnabled ? '已开' : '已关' }} + + + + 麦克风{{ audioEnabled ? '已开' : '已关' }} @@ -161,6 +118,7 @@ const meetingStore = useMeetingStore() const userStore = useUserStore() const rawCode = ref('') +const callMode = ref('video') const joining = ref(false) const joinError = ref('') const audioLoading = ref(false) @@ -171,6 +129,7 @@ const nowTs = ref(Date.now()) const ended = ref(false) let timerHandle = null let stateStopWatch = null +let videoRetryHandle = null const digitsOf = (value) => String(value || '').replace(/\D/g, '').slice(0, 9) const sameUser = (a, b) => String(a || '') === String(b || '') @@ -187,8 +146,7 @@ const hostId = computed(() => meetingStore.currentRoom?.host_id || '') const audioEnabled = computed(() => !!meetingStore.localAudioEnabled) const videoEnabled = computed(() => !!meetingStore.localVideoEnabled) const speakingMap = computed(() => meetingStore.speakingMap || {}) -const isVoiceMode = computed(() => !videoEnabled.value) -const speakerIcon = voiceOnIcon +const isVoiceMode = computed(() => callMode.value === 'voice') const cameraIcon = computed(() => videoEnabled.value ? videoOnIcon : videoOffIcon) const micIcon = computed(() => audioEnabled.value ? voiceOnIcon : voiceOffIcon) @@ -286,7 +244,7 @@ const tiles = computed(() => { }) const selfTile = computed(() => tiles.value.find(t => t.isLocal) || null) -const primaryTile = computed(() => tiles.value.find(t => !t.isLocal && t.videoTrack) || tiles.value.find(t => !t.isLocal) || selfTile.value) +const primaryTile = computed(() => tiles.value.find(t => !t.isLocal && t.videoTrack) || selfTile.value || tiles.value.find(t => !t.isLocal)) const voicePeer = computed(() => participants.value.find(p => !sameUser(p.user_id, myUserId.value)) || null) const voiceName = computed(() => { if (voicePeer.value?.user_name) return voicePeer.value.user_name @@ -363,21 +321,23 @@ const joinMeeting = async () => { if (meetingStore.isInMeeting) { await meetingStore.leave().catch(() => {}) } + const startWithVideo = !isVoiceMode.value meetingStore.devicePreview = { audioDeviceId: '', videoDeviceId: '', speakerDeviceId: '', displayName: userStore.userInfo?.nickname || userStore.userInfo?.username || '', startAudio: true, - startVideo: true + startVideo: startWithVideo } await meetingStore.joinAndEnter(code, '', { startAudio: true, - startVideo: true, + startVideo: startWithVideo, audioDeviceId: '', videoDeviceId: '' }) startTimer() + scheduleVideoRetry() } catch (err) { joinError.value = err?.message || '视频咨询接入失败' console.error('[CloudLawVideoCall] join failed', err) @@ -386,6 +346,19 @@ const joinMeeting = async () => { } } +const scheduleVideoRetry = () => { + if (isVoiceMode.value || videoRetryHandle || videoEnabled.value) return + videoRetryHandle = setTimeout(async () => { + videoRetryHandle = null + if (isVoiceMode.value || videoEnabled.value || leaving.value || !meetingStore.isInMeeting) return + try { + await meetingStore.startLocalVideo() + } catch (err) { + console.warn('[CloudLawVideoCall] retry start video failed', err) + } + }, 800) +} + const onMicToggle = async () => { if (audioLoading.value || leaving.value) return audioLoading.value = true @@ -433,6 +406,8 @@ const hangup = async () => { onLoad(query => { rawCode.value = query?.code || query?.roomCode || '' + const mode = String(query?.mode || query?.callType || query?.type || '').toLowerCase() + callMode.value = mode === 'voice' || mode === 'audio' ? 'voice' : 'video' joinMeeting() }) @@ -440,6 +415,7 @@ onMounted(() => { stateStopWatch = watch(() => meetingStore.localState, s => { if (s === MEETING_LOCAL_STATE_CONNECTED) { startTimer() + scheduleVideoRetry() } if (s === MEETING_LOCAL_STATE_ENDED) { const reason = meetingStore.lastEndedReason @@ -459,6 +435,10 @@ onBeforeUnmount(() => { stateStopWatch() stateStopWatch = null } + if (videoRetryHandle) { + clearTimeout(videoRetryHandle) + videoRetryHandle = null + } }) onUnload(() => { @@ -678,7 +658,7 @@ onUnload(() => { .voice-duration { position: absolute; left: 50%; - bottom: calc(216rpx + env(safe-area-inset-bottom)); + bottom: calc(310rpx + env(safe-area-inset-bottom)); transform: translateX(-50%); padding: 10rpx 20rpx; border-radius: 999rpx; @@ -692,23 +672,24 @@ onUnload(() => { position: absolute; left: 0; right: 0; - bottom: 0; + bottom: calc(112rpx + env(safe-area-inset-bottom)); box-sizing: border-box; z-index: 8; } .video-controls { - padding: 28rpx 64rpx calc(122rpx + env(safe-area-inset-bottom)); + padding: 0 58rpx; display: grid; grid-template-columns: repeat(3, 1fr); - column-gap: 46rpx; - background: linear-gradient(0deg, rgba(0, 0, 0, 0.54) 0%, rgba(0, 0, 0, 0.22) 62%, rgba(0, 0, 0, 0) 100%); + align-items: end; + column-gap: 44rpx; + background: transparent; } .voice-controls { - padding: 0 74rpx calc(54rpx + env(safe-area-inset-bottom)); + padding: 0 58rpx; display: grid; - grid-template-columns: 1fr 132rpx 1fr; + grid-template-columns: repeat(3, 1fr); align-items: end; - column-gap: 54rpx; + column-gap: 44rpx; background: transparent; } .voice-control-col { @@ -737,9 +718,9 @@ onUnload(() => { box-shadow: 0 10rpx 26rpx rgba(0, 0, 0, 0.18); } .dark-btn { - width: 104rpx; - height: 104rpx; - background: rgba(13, 36, 89, 0.78); + width: 112rpx; + height: 112rpx; + background: rgba(22, 43, 105, 0.86); border: 1rpx solid rgba(255, 255, 255, 0.06); box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.04); } @@ -752,12 +733,6 @@ onUnload(() => { background: #ff2f3f; box-shadow: 0 14rpx 36rpx rgba(255, 47, 63, 0.38); } -.hangup-center { - position: absolute; - left: 50%; - bottom: calc(32rpx + env(safe-area-inset-bottom)); - transform: translateX(-50%); -} .control-image { width: 58rpx; height: 58rpx; diff --git a/frontend/src/store/meeting.js b/frontend/src/store/meeting.js index 4623f8c..75f6363 100644 --- a/frontend/src/store/meeting.js +++ b/frontend/src/store/meeting.js @@ -1202,9 +1202,10 @@ export const useMeetingStore = defineStore('meeting', () => { let track = existingTrack && existingTrack.readyState === 'live' ? existingTrack : null if (!track) { const videoConstraints = { - width: { ideal: 1280 }, - height: { ideal: 720 }, - frameRate: { ideal: 24, max: 30 }, + width: { ideal: 640 }, + height: { ideal: 480 }, + frameRate: { ideal: 15, max: 24 }, + facingMode: 'user', ...(deviceId ? { deviceId: { exact: deviceId } } : {}) } const stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints })