视频会议

This commit is contained in:
duoaohui
2026-05-23 17:51:35 +08:00
parent 759ddb513d
commit 3e56a6c3db
3 changed files with 59 additions and 74 deletions

View File

@@ -84,6 +84,7 @@ const ensureMediaEl = (tagName) => {
el.style.height = '100%' el.style.height = '100%'
el.style.objectFit = 'cover' el.style.objectFit = 'cover'
el.style.background = '#0B1220' el.style.background = '#0B1220'
el.style.display = 'block'
} }
parent.appendChild(el) parent.appendChild(el)
} }
@@ -123,6 +124,10 @@ const applyVideo = () => {
if (el) { if (el) {
el.muted = props.isLocal el.muted = props.isLocal
el.srcObject = new MediaStream([props.videoTrack]) el.srcObject = new MediaStream([props.videoTrack])
const playPromise = el.play && el.play()
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {})
}
} }
} else { } else {
removeMediaEl('video') removeMediaEl('video')
@@ -137,6 +142,10 @@ const applyAudio = () => {
const el = ensureMediaEl('audio') const el = ensureMediaEl('audio')
if (el) { if (el) {
el.srcObject = new MediaStream([props.audioTrack]) el.srcObject = new MediaStream([props.audioTrack])
const playPromise = el.play && el.play()
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {})
}
} }
} else { } else {
removeMediaEl('audio') removeMediaEl('audio')

View File

@@ -66,50 +66,14 @@
<view v-if="isVoiceMode" class="voice-duration">{{ durationText }}</view> <view v-if="isVoiceMode" class="voice-duration">{{ durationText }}</view>
<view v-if="!isVoiceMode" class="controls video-controls"> <view class="controls" :class="isVoiceMode ? 'voice-controls' : 'video-controls'">
<view class="control-item" @click="onMicToggle">
<view class="control-btn white-btn" :class="{ disabled: !audioEnabled, loading: audioLoading }">
<image class="control-image" :src="micIcon" mode="aspectFit"></image>
</view>
<text class="control-label">麦克风{{ audioEnabled ? '已开' : '已关' }}</text>
</view>
<view class="control-item">
<view class="control-btn white-btn">
<image class="control-image" :src="speakerIcon" mode="aspectFit"></image>
</view>
<text class="control-label">扬声器已开</text>
</view>
<view class="control-item" @click="onCamToggle"> <view class="control-item" @click="onCamToggle">
<view class="control-btn white-btn" :class="{ disabled: !videoEnabled, loading: videoLoading }"> <view class="control-btn dark-btn" :class="{ disabled: !videoEnabled, loading: videoLoading }">
<image class="control-image" :src="cameraIcon" mode="aspectFit"></image> <image class="control-image" :src="cameraIcon" mode="aspectFit"></image>
</view> </view>
<text class="control-label">摄像头{{ videoEnabled ? '已开' : '已关' }}</text> <text class="control-label">摄像头{{ videoEnabled ? '已开' : '已关' }}</text>
</view> </view>
<view class="hangup-center" @click="hangup">
<view class="hangup-btn" :class="{ loading: leaving }">
<image class="hangup-image" :src="hangupIcon" mode="aspectFit"></image>
</view>
</view>
</view>
<view v-else class="controls voice-controls">
<view class="voice-control-col">
<view class="control-item" @click="onCamToggle">
<view class="control-btn dark-btn" :class="{ disabled: !videoEnabled, loading: videoLoading }">
<image class="control-image" :src="cameraIcon" mode="aspectFit"></image>
</view>
</view>
<view class="control-item" @click="onCamToggle">
<view class="control-btn dark-btn" :class="{ disabled: !videoEnabled, loading: videoLoading }">
<image class="control-image" :src="cameraIcon" mode="aspectFit"></image>
</view>
<text class="control-label">摄像头已关</text>
</view>
</view>
<view class="control-item" @click="hangup"> <view class="control-item" @click="hangup">
<view class="hangup-btn" :class="{ loading: leaving }"> <view class="hangup-btn" :class="{ loading: leaving }">
<image class="hangup-image" :src="hangupIcon" mode="aspectFit"></image> <image class="hangup-image" :src="hangupIcon" mode="aspectFit"></image>
@@ -117,12 +81,6 @@
<text class="control-label">挂断</text> <text class="control-label">挂断</text>
</view> </view>
<view class="voice-control-col">
<view class="control-item" @click="onMicToggle">
<view class="control-btn dark-btn" :class="{ disabled: !audioEnabled, loading: audioLoading }">
<image class="control-image" :src="micIcon" mode="aspectFit"></image>
</view>
</view>
<view class="control-item" @click="onMicToggle"> <view class="control-item" @click="onMicToggle">
<view class="control-btn dark-btn" :class="{ disabled: !audioEnabled, loading: audioLoading }"> <view class="control-btn dark-btn" :class="{ disabled: !audioEnabled, loading: audioLoading }">
<image class="control-image" :src="micIcon" mode="aspectFit"></image> <image class="control-image" :src="micIcon" mode="aspectFit"></image>
@@ -132,7 +90,6 @@
</view> </view>
</view> </view>
</view> </view>
</view>
</template> </template>
<script setup> <script setup>
@@ -161,6 +118,7 @@ const meetingStore = useMeetingStore()
const userStore = useUserStore() const userStore = useUserStore()
const rawCode = ref('') const rawCode = ref('')
const callMode = ref('video')
const joining = ref(false) const joining = ref(false)
const joinError = ref('') const joinError = ref('')
const audioLoading = ref(false) const audioLoading = ref(false)
@@ -171,6 +129,7 @@ const nowTs = ref(Date.now())
const ended = ref(false) const ended = ref(false)
let timerHandle = null let timerHandle = null
let stateStopWatch = null let stateStopWatch = null
let videoRetryHandle = null
const digitsOf = (value) => String(value || '').replace(/\D/g, '').slice(0, 9) const digitsOf = (value) => String(value || '').replace(/\D/g, '').slice(0, 9)
const sameUser = (a, b) => String(a || '') === String(b || '') 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 audioEnabled = computed(() => !!meetingStore.localAudioEnabled)
const videoEnabled = computed(() => !!meetingStore.localVideoEnabled) const videoEnabled = computed(() => !!meetingStore.localVideoEnabled)
const speakingMap = computed(() => meetingStore.speakingMap || {}) const speakingMap = computed(() => meetingStore.speakingMap || {})
const isVoiceMode = computed(() => !videoEnabled.value) const isVoiceMode = computed(() => callMode.value === 'voice')
const speakerIcon = voiceOnIcon
const cameraIcon = computed(() => videoEnabled.value ? videoOnIcon : videoOffIcon) const cameraIcon = computed(() => videoEnabled.value ? videoOnIcon : videoOffIcon)
const micIcon = computed(() => audioEnabled.value ? voiceOnIcon : voiceOffIcon) 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 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 voicePeer = computed(() => participants.value.find(p => !sameUser(p.user_id, myUserId.value)) || null)
const voiceName = computed(() => { const voiceName = computed(() => {
if (voicePeer.value?.user_name) return voicePeer.value.user_name if (voicePeer.value?.user_name) return voicePeer.value.user_name
@@ -363,21 +321,23 @@ const joinMeeting = async () => {
if (meetingStore.isInMeeting) { if (meetingStore.isInMeeting) {
await meetingStore.leave().catch(() => {}) await meetingStore.leave().catch(() => {})
} }
const startWithVideo = !isVoiceMode.value
meetingStore.devicePreview = { meetingStore.devicePreview = {
audioDeviceId: '', audioDeviceId: '',
videoDeviceId: '', videoDeviceId: '',
speakerDeviceId: '', speakerDeviceId: '',
displayName: userStore.userInfo?.nickname || userStore.userInfo?.username || '', displayName: userStore.userInfo?.nickname || userStore.userInfo?.username || '',
startAudio: true, startAudio: true,
startVideo: true startVideo: startWithVideo
} }
await meetingStore.joinAndEnter(code, '', { await meetingStore.joinAndEnter(code, '', {
startAudio: true, startAudio: true,
startVideo: true, startVideo: startWithVideo,
audioDeviceId: '', audioDeviceId: '',
videoDeviceId: '' videoDeviceId: ''
}) })
startTimer() startTimer()
scheduleVideoRetry()
} catch (err) { } catch (err) {
joinError.value = err?.message || '视频咨询接入失败' joinError.value = err?.message || '视频咨询接入失败'
console.error('[CloudLawVideoCall] join failed', err) 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 () => { const onMicToggle = async () => {
if (audioLoading.value || leaving.value) return if (audioLoading.value || leaving.value) return
audioLoading.value = true audioLoading.value = true
@@ -433,6 +406,8 @@ const hangup = async () => {
onLoad(query => { onLoad(query => {
rawCode.value = query?.code || query?.roomCode || '' 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() joinMeeting()
}) })
@@ -440,6 +415,7 @@ onMounted(() => {
stateStopWatch = watch(() => meetingStore.localState, s => { stateStopWatch = watch(() => meetingStore.localState, s => {
if (s === MEETING_LOCAL_STATE_CONNECTED) { if (s === MEETING_LOCAL_STATE_CONNECTED) {
startTimer() startTimer()
scheduleVideoRetry()
} }
if (s === MEETING_LOCAL_STATE_ENDED) { if (s === MEETING_LOCAL_STATE_ENDED) {
const reason = meetingStore.lastEndedReason const reason = meetingStore.lastEndedReason
@@ -459,6 +435,10 @@ onBeforeUnmount(() => {
stateStopWatch() stateStopWatch()
stateStopWatch = null stateStopWatch = null
} }
if (videoRetryHandle) {
clearTimeout(videoRetryHandle)
videoRetryHandle = null
}
}) })
onUnload(() => { onUnload(() => {
@@ -678,7 +658,7 @@ onUnload(() => {
.voice-duration { .voice-duration {
position: absolute; position: absolute;
left: 50%; left: 50%;
bottom: calc(216rpx + env(safe-area-inset-bottom)); bottom: calc(310rpx + env(safe-area-inset-bottom));
transform: translateX(-50%); transform: translateX(-50%);
padding: 10rpx 20rpx; padding: 10rpx 20rpx;
border-radius: 999rpx; border-radius: 999rpx;
@@ -692,23 +672,24 @@ onUnload(() => {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: calc(112rpx + env(safe-area-inset-bottom));
box-sizing: border-box; box-sizing: border-box;
z-index: 8; z-index: 8;
} }
.video-controls { .video-controls {
padding: 28rpx 64rpx calc(122rpx + env(safe-area-inset-bottom)); padding: 0 58rpx;
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
column-gap: 46rpx; align-items: end;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.54) 0%, rgba(0, 0, 0, 0.22) 62%, rgba(0, 0, 0, 0) 100%); column-gap: 44rpx;
background: transparent;
} }
.voice-controls { .voice-controls {
padding: 0 74rpx calc(54rpx + env(safe-area-inset-bottom)); padding: 0 58rpx;
display: grid; display: grid;
grid-template-columns: 1fr 132rpx 1fr; grid-template-columns: repeat(3, 1fr);
align-items: end; align-items: end;
column-gap: 54rpx; column-gap: 44rpx;
background: transparent; background: transparent;
} }
.voice-control-col { .voice-control-col {
@@ -737,9 +718,9 @@ onUnload(() => {
box-shadow: 0 10rpx 26rpx rgba(0, 0, 0, 0.18); box-shadow: 0 10rpx 26rpx rgba(0, 0, 0, 0.18);
} }
.dark-btn { .dark-btn {
width: 104rpx; width: 112rpx;
height: 104rpx; height: 112rpx;
background: rgba(13, 36, 89, 0.78); background: rgba(22, 43, 105, 0.86);
border: 1rpx solid rgba(255, 255, 255, 0.06); border: 1rpx solid rgba(255, 255, 255, 0.06);
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.04); box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.04);
} }
@@ -752,12 +733,6 @@ onUnload(() => {
background: #ff2f3f; background: #ff2f3f;
box-shadow: 0 14rpx 36rpx rgba(255, 47, 63, 0.38); 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 { .control-image {
width: 58rpx; width: 58rpx;
height: 58rpx; height: 58rpx;

View File

@@ -1202,9 +1202,10 @@ export const useMeetingStore = defineStore('meeting', () => {
let track = existingTrack && existingTrack.readyState === 'live' ? existingTrack : null let track = existingTrack && existingTrack.readyState === 'live' ? existingTrack : null
if (!track) { if (!track) {
const videoConstraints = { const videoConstraints = {
width: { ideal: 1280 }, width: { ideal: 640 },
height: { ideal: 720 }, height: { ideal: 480 },
frameRate: { ideal: 24, max: 30 }, frameRate: { ideal: 15, max: 24 },
facingMode: 'user',
...(deviceId ? { deviceId: { exact: deviceId } } : {}) ...(deviceId ? { deviceId: { exact: deviceId } } : {})
} }
const stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints }) const stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints })