Files
EchoChat/frontend/src/pages/cloud-law/video-call.vue
2026-05-23 21:42:49 +08:00

848 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="cloud-law-call">
<view v-if="!isReady" class="entry-state">
<view class="entry-avatar">{{ entryInitial }}</view>
<text class="entry-title">{{ entryTitle }}</text>
<text class="entry-desc">{{ joinError || stateText }}</text>
<button v-if="joinError" class="retry-btn" @click="joinMeeting">重新进入</button>
</view>
<view v-else class="call-shell" :class="{ 'voice-mode': isVoiceMode }">
<view v-if="!isVoiceMode" class="remote-stage">
<VideoTile
v-if="primaryTile"
:key="`main-${primaryTile.userId}-${primaryTile.videoTrack ? primaryTile.videoTrack.id : 'novideo'}`"
class="main-video clean-video-tile"
:user-id="primaryTile.userId"
:name="primaryTile.name"
:is-local="primaryTile.isLocal"
:is-host="primaryTile.isHost"
:audio-enabled="primaryTile.audioEnabled"
:video-enabled="primaryTile.videoEnabled"
:audio-track="primaryTile.audioTrack"
:video-track="primaryTile.videoTrack"
:is-speaking="!!speakingMap[primaryTile.userId]"
/>
<view v-else class="empty-video">
<view class="entry-avatar">{{ entryInitial }}</view>
<text class="entry-title">在线律师</text>
<text class="entry-desc">等待视频画面</text>
</view>
</view>
<view v-else class="voice-stage">
<image class="voice-bg" :src="bgImage" mode="aspectFill"></image>
<view class="voice-profile">
<image v-if="voiceAvatar" class="voice-avatar" :src="voiceAvatar" mode="aspectFill"></image>
<view v-else class="voice-avatar voice-avatar-fallback">{{ voiceInitial }}</view>
<text class="voice-name">{{ voiceName }}</text>
</view>
</view>
<view v-if="!isVoiceMode && secondaryTile" class="self-preview" @click="swapPrimaryTile">
<VideoTile
:key="`pip-${secondaryTile.userId}-${secondaryTile.videoTrack ? secondaryTile.videoTrack.id : 'novideo'}`"
class="self-video clean-video-tile"
:user-id="secondaryTile.userId"
:name="secondaryTile.name"
:is-local="secondaryTile.isLocal"
:is-host="secondaryTile.isHost"
:audio-enabled="secondaryTile.audioEnabled"
:video-enabled="secondaryTile.videoEnabled"
:audio-track="secondaryTile.audioTrack"
:video-track="secondaryTile.videoTrack"
:is-speaking="!!speakingMap[secondaryTile.userId]"
:compact="true"
/>
</view>
<view v-if="!isVoiceMode" class="video-topbar">
<view class="top-window-icon" @click="swapPrimaryTile"></view>
<text class="top-duration">{{ durationText }}</text>
<text class="top-plus"></text>
</view>
<view v-else class="voice-topbar">
<text class="voice-back" @click="hangup"></text>
</view>
<view v-if="isVoiceMode" class="voice-duration">{{ durationText }}</view>
<view class="controls" :class="isVoiceMode ? 'voice-controls' : 'video-controls'">
<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">摄像头{{ videoEnabled ? '已开' : '已关' }}</text>
</view>
<view class="control-item" @click="hangup">
<view class="hangup-btn" :class="{ loading: leaving }">
<image class="hangup-image" :src="hangupIcon" mode="aspectFit"></image>
</view>
<text class="control-label">挂断</text>
</view>
<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>
<text class="control-label">麦克风{{ audioEnabled ? '已开' : '已关' }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { useMeetingStore } from '@/store/meeting'
import { useUserStore } from '@/store/user'
import {
MEETING_LOCAL_STATE_CONNECTED,
MEETING_LOCAL_STATE_CONNECTING,
MEETING_LOCAL_STATE_JOINING,
MEETING_LOCAL_STATE_RECONNECTING,
MEETING_LOCAL_STATE_LEAVING,
MEETING_LOCAL_STATE_ENDED,
MEETING_ENDED_REASON_LABEL
} from '@/constants/meeting'
import VideoTile from '@/components/meeting/VideoTile.vue'
import bgImage from '@/static/bg.png'
import hangupIcon from '@/static/cacel.png'
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'
const meetingStore = useMeetingStore()
const userStore = useUserStore()
const rawCode = ref('')
const callMode = ref('video')
const joining = ref(false)
const joinError = ref('')
const audioLoading = ref(false)
const videoLoading = ref(false)
const leaving = ref(false)
const startedAt = ref(0)
const nowTs = ref(Date.now())
const ended = ref(false)
const preferSelfMain = 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 || '')
const formatCode = (value) => {
const digits = digitsOf(value)
if (digits.length !== 9) return String(value || '')
return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`
}
const formattedCode = computed(() => formatCode(rawCode.value))
const myUserId = computed(() => userStore.userInfo?.id || userStore.userInfo?.user_id || '')
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(() => callMode.value === 'voice')
const cameraIcon = computed(() => videoEnabled.value ? videoOnIcon : videoOffIcon)
const micIcon = computed(() => audioEnabled.value ? voiceOnIcon : voiceOffIcon)
const entryInitial = computed(() => {
const name = userStore.userInfo?.nickname || userStore.userInfo?.username || '在'
return String(name || '在').slice(0, 1)
})
const entryTitle = computed(() => {
if (joinError.value) return '视频咨询接入失败'
if (ended.value) return '视频咨询已结束'
return '正在进入视频咨询'
})
const participants = computed(() => {
const list = meetingStore.activeParticipants || []
return list.map(p => {
if (!sameUser(p.user_id, myUserId.value)) return p
return {
...p,
audio_enabled: meetingStore.localAudioEnabled,
video_enabled: meetingStore.localVideoEnabled
}
})
})
const localTrackVersion = computed(() => {
const producers = meetingStore.localProducers || {}
return `${producers.audio || ''}-${producers.video || ''}`
})
const remoteTrackVersion = computed(() => {
const parts = []
const map = meetingStore.remoteConsumers || {}
Object.keys(map).forEach(uid => {
const slot = map[uid]
parts.push(`${uid}:${slot?.audio?.id || ''}:${slot?.video?.id || ''}`)
})
return parts.join('|')
})
const displayNameMap = computed(() => {
const map = {}
participants.value.forEach(p => {
if (p.user_name) map[p.user_id] = p.user_name
})
const me = userStore.userInfo || {}
const uid = me.id || me.user_id
if (uid) map[uid] = me.nickname || me.username || map[uid]
return map
})
const nameOf = (uid) => {
if (sameUser(uid, myUserId.value)) return '你'
return displayNameMap.value[uid] || `用户 ${uid}`
}
const tiles = computed(() => {
localTrackVersion.value
remoteTrackVersion.value
const list = []
participants.value.forEach(p => {
if (!p.is_active && p.is_active !== undefined) return
const isLocal = sameUser(p.user_id, myUserId.value)
const audioTrack = isLocal ? meetingStore.getLocalTrack('audio') : meetingStore.getRemoteTrack(p.user_id, 'audio')
const videoTrack = isLocal ? meetingStore.getLocalTrack('video') : meetingStore.getRemoteTrack(p.user_id, 'video')
list.push({
key: `u_${p.user_id}`,
userId: p.user_id,
name: nameOf(p.user_id),
isLocal,
isHost: sameUser(p.user_id, hostId.value),
audioEnabled: isLocal ? audioEnabled.value : !!audioTrack,
videoEnabled: isLocal ? videoEnabled.value : !!videoTrack,
audioTrack,
videoTrack
})
})
if (myUserId.value && !list.some(t => t.isLocal)) {
list.unshift({
key: `u_${myUserId.value}`,
userId: myUserId.value,
name: '你',
isLocal: true,
isHost: sameUser(myUserId.value, hostId.value),
audioEnabled: audioEnabled.value,
videoEnabled: videoEnabled.value,
audioTrack: meetingStore.getLocalTrack('audio'),
videoTrack: meetingStore.getLocalTrack('video')
})
}
return list
})
const selfTile = computed(() => tiles.value.find(t => t.isLocal) || null)
const remoteTile = computed(() => tiles.value.find(t => !t.isLocal && t.videoTrack) || tiles.value.find(t => !t.isLocal) || null)
const primaryTile = computed(() => {
if (preferSelfMain.value && selfTile.value) return selfTile.value
return remoteTile.value || selfTile.value
})
const secondaryTile = computed(() => {
if (!primaryTile.value) return null
if (primaryTile.value.isLocal) return remoteTile.value
return selfTile.value
})
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
if (primaryTile.value && !primaryTile.value.isLocal) return primaryTile.value.name
return '在线律师'
})
const voiceAvatar = computed(() => voicePeer.value?.user_avatar || '')
const voiceInitial = computed(() => String(voiceName.value || '在').slice(0, 1))
const isReady = computed(() => meetingStore.localState === MEETING_LOCAL_STATE_CONNECTED && !joinError.value && !ended.value)
const stateClass = computed(() => {
switch (meetingStore.localState) {
case MEETING_LOCAL_STATE_CONNECTED: return 'connected'
case MEETING_LOCAL_STATE_RECONNECTING: return 'reconnecting'
case MEETING_LOCAL_STATE_CONNECTING:
case MEETING_LOCAL_STATE_JOINING: return 'connecting'
case MEETING_LOCAL_STATE_LEAVING: return 'leaving'
case MEETING_LOCAL_STATE_ENDED: return 'ended'
default: return 'idle'
}
})
const stateText = computed(() => {
if (joinError.value) return joinError.value
if (joining.value) return '正在接入视频画面…'
switch (meetingStore.localState) {
case MEETING_LOCAL_STATE_CONNECTED: return '已接通'
case MEETING_LOCAL_STATE_RECONNECTING: return '重连中'
case MEETING_LOCAL_STATE_CONNECTING:
case MEETING_LOCAL_STATE_JOINING: return '连接中'
case MEETING_LOCAL_STATE_LEAVING: return '正在挂断'
case MEETING_LOCAL_STATE_ENDED: return '已结束'
default: return '等待接通'
}
})
const durationText = computed(() => {
if (!startedAt.value) return '00:00'
const diff = Math.max(0, Math.floor((nowTs.value - startedAt.value) / 1000))
const h = Math.floor(diff / 3600)
const m = Math.floor((diff % 3600) / 60)
const s = diff % 60
const pad = n => (n < 10 ? `0${n}` : String(n))
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
})
const startTimer = () => {
if (!startedAt.value) {
const startIso = meetingStore.currentRoom?.started_at || meetingStore.currentRoom?.created_at
startedAt.value = startIso ? Date.parse(startIso) : Date.now()
}
if (!timerHandle) {
timerHandle = setInterval(() => { nowTs.value = Date.now() }, 1000)
}
}
const localMediaError = ref('')
const joinMeeting = async () => {
if (joining.value) return
const code = formattedCode.value
if (digitsOf(code).length !== 9) {
joinError.value = '会议号为空'
return
}
joining.value = true
joinError.value = ''
ended.value = false
try {
const currentCode = formatCode(meetingStore.currentRoom?.room_code || '')
if (meetingStore.isInMeeting && currentCode && digitsOf(currentCode) === digitsOf(code)) {
startTimer()
return
}
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: startWithVideo
}
// 关键:和 preview.vue 一致,进会前先 getUserMedia 拿到 audio+video 的原始 track
// 再把 track 直接交给 joinAndEnter 复用,避免 mediasoup 入会后再次 getUserMedia
// 在 iOS Safari/微信 WebView 上触发 NotReadableError / 权限拒绝。
const prefs = await prepareLocalMediaTracks(startWithVideo)
await meetingStore.joinAndEnter(code, '', prefs)
startTimer()
scheduleVideoRetry()
} catch (err) {
joinError.value = err?.message || '视频咨询接入失败'
console.error('[CloudLawVideoCall] join failed', err)
} finally {
joining.value = false
}
}
/**
* 进会前同步申请本地音视频。与 preview.vue 同策略:
* 一次 getUserMedia 拿到 audio + video track 直接交给 mediasoup produce
* 避免入会后再调 getUserMedia 在某些 WebView/Safari 里失败导致黑屏。
*/
const prepareLocalMediaTracks = async (startWithVideo) => {
const prefs = {
startAudio: true,
startVideo: startWithVideo,
audioDeviceId: '',
videoDeviceId: '',
audioTrack: null,
videoTrack: null
}
// #ifdef H5
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.warn('[CloudLawVideoCall] navigator.mediaDevices 不可用,跳过预申请')
return prefs
}
try {
const constraints = {
audio: true,
video: startWithVideo
? { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 15, max: 24 }, facingMode: 'user' }
: false
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
prefs.audioTrack = stream.getAudioTracks()[0] || null
prefs.videoTrack = stream.getVideoTracks()[0] || null
// 如果用户拒了视频stream 里不会有 video track自动降级为不开摄像头
if (startWithVideo && !prefs.videoTrack) {
prefs.startVideo = false
}
if (!prefs.audioTrack) {
prefs.startAudio = false
}
} catch (err) {
console.error('[CloudLawVideoCall] prepare media failed', err)
localMediaError.value = `预申请音视频失败:${err?.name || ''} ${err?.message || err}`
// 拿不到也继续走 joinAndEnter让后端协议建好用户可以在入会后手动点麦/摄像头按钮重试
prefs.startAudio = false
prefs.startVideo = false
}
// #endif
return prefs
}
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.error('[CloudLawVideoCall] retry start video failed', err)
localMediaError.value = `自动开摄像头失败:${err?.message || err}`
}
if (!meetingStore.localAudioEnabled) {
try {
await meetingStore.startLocalAudio()
} catch (err) {
console.error('[CloudLawVideoCall] retry start audio failed', err)
localMediaError.value = `${localMediaError.value || ''} | 自动开麦克风失败:${err?.message || err}`
}
}
}, 800)
}
const swapPrimaryTile = () => {
if (!selfTile.value || !remoteTile.value) return
preferSelfMain.value = !preferSelfMain.value
}
const onMicToggle = async () => {
if (audioLoading.value || leaving.value) return
audioLoading.value = true
try {
if (audioEnabled.value) {
await meetingStore.stopLocalAudio()
} else {
await meetingStore.startLocalAudio()
localMediaError.value = ''
}
} catch (err) {
console.error('[CloudLawVideoCall] mic toggle failed', err)
const msg = err?.message || '麦克风操作失败'
localMediaError.value = `麦克风:${msg}`
uni.showModal({ title: '麦克风开启失败', content: msg, showCancel: false })
} finally {
audioLoading.value = false
}
}
const onCamToggle = async () => {
if (videoLoading.value || leaving.value) return
videoLoading.value = true
try {
if (videoEnabled.value) {
await meetingStore.stopLocalVideo()
} else {
await meetingStore.startLocalVideo()
localMediaError.value = ''
}
} catch (err) {
console.error('[CloudLawVideoCall] cam toggle failed', err)
const msg = err?.message || '摄像头操作失败'
localMediaError.value = `摄像头:${msg}`
uni.showModal({ title: '摄像头开启失败', content: msg, showCancel: false })
} finally {
videoLoading.value = false
}
}
const hangup = async () => {
if (leaving.value) return
leaving.value = true
try {
await meetingStore.leave()
} catch (err) {
console.warn('[CloudLawVideoCall] leave failed', err)
} finally {
ended.value = true
leaving.value = false
}
}
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()
})
onMounted(() => {
stateStopWatch = watch(() => meetingStore.localState, s => {
if (s === MEETING_LOCAL_STATE_CONNECTED) {
startTimer()
scheduleVideoRetry()
}
if (s === MEETING_LOCAL_STATE_ENDED) {
const reason = meetingStore.lastEndedReason
const label = MEETING_ENDED_REASON_LABEL?.[reason] || '视频咨询已结束'
uni.showToast({ title: label, icon: 'none' })
ended.value = true
}
}, { immediate: true })
})
onBeforeUnmount(() => {
if (timerHandle) {
clearInterval(timerHandle)
timerHandle = null
}
if (stateStopWatch) {
stateStopWatch()
stateStopWatch = null
}
if (videoRetryHandle) {
clearTimeout(videoRetryHandle)
videoRetryHandle = null
}
})
onUnload(() => {
if (meetingStore.isInMeeting && meetingStore.localState !== MEETING_LOCAL_STATE_LEAVING) {
meetingStore.leave().catch(() => {})
}
})
</script>
<style scoped>
.cloud-law-call {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
background: linear-gradient(180deg, #32175f 0%, #071025 38%, #050b1d 100%);
color: #ffffff;
z-index: 1000;
}
.entry-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48rpx;
box-sizing: border-box;
text-align: center;
}
.entry-avatar {
width: 136rpx;
height: 136rpx;
border-radius: 50%;
background: linear-gradient(135deg, #43b1ff 0%, #1974e8 100%);
border: 4rpx solid rgba(255, 255, 255, 0.75);
display: flex;
align-items: center;
justify-content: center;
font-size: 56rpx;
font-weight: 700;
box-shadow: 0 18rpx 60rpx rgba(25, 116, 232, 0.36);
}
.entry-title {
margin-top: 28rpx;
font-size: 34rpx;
font-weight: 700;
}
.entry-desc {
margin-top: 14rpx;
max-width: 560rpx;
font-size: 24rpx;
line-height: 1.5;
color: rgba(255, 255, 255, 0.72);
}
.retry-btn {
margin-top: 36rpx;
width: 260rpx;
height: 76rpx;
line-height: 76rpx;
border-radius: 999rpx;
background: #1664ff;
color: #ffffff;
font-size: 28rpx;
}
.call-shell {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.remote-stage {
position: absolute;
inset: 0;
background: #050b1d;
}
.voice-stage {
position: absolute;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
overflow: hidden;
}
.voice-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.voice-profile {
position: relative;
z-index: 2;
margin-top: calc(126rpx + env(safe-area-inset-top));
display: flex;
flex-direction: column;
align-items: center;
}
.voice-avatar {
width: 132rpx;
height: 132rpx;
border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.86);
box-shadow: 0 12rpx 36rpx rgba(0, 0, 0, 0.28);
}
.voice-avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #43b1ff 0%, #1974e8 100%);
color: #ffffff;
font-size: 54rpx;
font-weight: 700;
}
.voice-name {
margin-top: 24rpx;
font-size: 28rpx;
line-height: 1;
color: #ffffff;
font-weight: 600;
}
.main-video {
width: 100%;
height: 100%;
border-radius: 0;
}
.empty-video {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.self-preview {
position: absolute;
right: 30rpx;
top: calc(104rpx + env(safe-area-inset-top));
width: 220rpx;
height: 310rpx;
border-radius: 10rpx;
overflow: hidden;
border: 0;
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.38);
background: #0b1220;
z-index: 5;
}
.self-video {
width: 100%;
height: 100%;
border-radius: 0;
}
.video-topbar {
position: absolute;
left: 0;
right: 0;
top: 0;
height: calc(88rpx + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 46rpx 0;
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 100%);
box-sizing: border-box;
z-index: 6;
}
.top-window-icon {
position: relative;
width: 42rpx;
height: 42rpx;
border: 4rpx solid rgba(255, 255, 255, 0.9);
border-radius: 6rpx;
box-sizing: border-box;
}
.top-window-icon::after {
content: '';
position: absolute;
right: -16rpx;
bottom: -14rpx;
width: 24rpx;
height: 24rpx;
border: 4rpx solid rgba(255, 255, 255, 0.9);
border-radius: 5rpx;
box-sizing: border-box;
}
.top-duration {
font-size: 36rpx;
color: #ffffff;
font-weight: 500;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.35);
}
.top-plus {
color: #ffffff;
font-size: 56rpx;
line-height: 1;
font-weight: 300;
}
.voice-topbar {
position: absolute;
left: 0;
right: 0;
top: 0;
height: calc(88rpx + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 26rpx 0;
display: flex;
align-items: center;
box-sizing: border-box;
z-index: 6;
}
.voice-back {
color: #ffffff;
font-size: 54rpx;
line-height: 1;
font-weight: 300;
}
.voice-duration {
position: absolute;
left: 50%;
bottom: calc(310rpx + env(safe-area-inset-bottom));
transform: translateX(-50%);
padding: 10rpx 20rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.16);
color: rgba(255, 255, 255, 0.78);
font-size: 24rpx;
line-height: 1;
z-index: 7;
}
.controls {
position: absolute;
left: 0;
right: 0;
bottom: calc(112rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
z-index: 8;
}
.video-controls {
padding: 0 58rpx;
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: end;
column-gap: 44rpx;
background: transparent;
}
.voice-controls {
padding: 0 58rpx;
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;
}
.control-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 18rpx;
}
.control-btn,
.hangup-btn {
width: 150rpx;
height: 150rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.white-btn {
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 10rpx 26rpx rgba(0, 0, 0, 0.18);
}
.dark-btn {
width: 132rpx;
height: 132rpx;
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);
}
.control-btn.disabled {
opacity: 0.94;
}
.hangup-btn {
width: 150rpx;
height: 150rpx;
background: #ff2f3f;
box-shadow: 0 14rpx 36rpx rgba(255, 47, 63, 0.38);
}
.control-image {
width: 170rpx;
height: 170rpx;
}
.dark-btn .control-image {
width: 108rpx;
height: 108rpx;
}
.hangup-image {
width: 150rpx;
height: 150rpx;
}
.control-label {
font-size: 24rpx;
line-height: 1;
color: rgba(255, 255, 255, 0.82);
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
.loading {
opacity: 0.68;
}
.clean-video-tile :deep(.footer) {
display: none;
}
.clean-video-tile :deep(.tag-self) {
display: none;
}
</style>