视频会议
This commit is contained in:
@@ -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')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user