Files
zyt/TUICallKit-Vue3/TUIKit/components/CallView/CallView.vue
T
2026-05-07 16:42:29 +08:00

582 lines
18 KiB
Vue

<template>
<!-- 音频/视频通话主容器 -->
<div class="TUICall-container" v-if="callParticipantInfo?.selfInfo?.status !== CallStatus.IDLE">
<!-- 顶部菜单栏 -->
<view class='topBar' v-if="callParticipantInfo?.selfInfo?.status === CallStatus.CONNECTED">
<view class="call-duration">
<text>{{ formatDuration(duration) }}</text>
</view>
</view>
<!-- 本地头像遮罩层 -->
<view @click="!isLocalStreamMain && toggleViewSize()" :class="isLocalStreamMain ? 'big-overlay' : 'small-overlay'"
v-if="mediaType === CallMediaType.VIDEO && !isCameraOpen">
<Avatar :avatarStyle="isLocalStreamMain ? bigOverlayAvatarStyle : smallOverlayAvatarStyle"
:src="callParticipantInfo?.selfInfo.avatarUrl || IMG_DEFAULT_AVATAR" />
<view :class="isLocalStreamMain ? 'big-overlay-mask' : 'small-overlay-mask'"></view>
</view>
<!-- 本地流 -->
<div
:class="(mediaType === CallMediaType.AUDIO || !isCameraOpen) ? 'player-audio' : (isLocalStreamMain ? 'big-video' : 'small-video')"
@click="!isLocalStreamMain && toggleViewSize()">
<TRTCPusher :key="pusherId" :id="pusherId" v-if="pusherId" />
</div>
<!-- 远端头像遮罩层 -->
<view :class="!isLocalStreamMain ? 'big-overlay' : 'small-overlay'" @click="isLocalStreamMain && toggleViewSize()"
v-if="mediaType === CallMediaType.AUDIO || (mediaType === CallMediaType.VIDEO && callParticipantInfo?.selfInfo?.status === CallStatus.CONNECTED && !callParticipantInfo?.allParticipants[0]?.isCameraOpened)">
<Avatar :avatarStyle="!isLocalStreamMain ? bigOverlayAvatarStyle : smallOverlayAvatarStyle"
:src="callParticipantInfo?.allParticipants[0]?.avatarUrl || IMG_DEFAULT_AVATAR" />
<view :class="!isLocalStreamMain ? 'big-overlay-mask' : 'small-overlay-mask'"></view>
</view>
<!-- 远端流 -->
<view
:class="(mediaType === CallMediaType.AUDIO || callParticipantInfo?.selfInfo?.status === CallStatus.CALLING || !callParticipantInfo?.allParticipants[0]?.isCameraOpened) ? 'player-audio' : (!isLocalStreamMain ? 'big-video' : 'small-video')"
@click="isLocalStreamMain && toggleViewSize()">
<TRTCPlayer :id="`${callParticipantInfo?.allParticipants[0]?.id}_0`" ref="player"
:stream-id="`${callParticipantInfo?.allParticipants[0]?.id}_0`" />
</view>
<!-- 用户信息 -->
<view v-if="mediaType === CallMediaType.AUDIO || callParticipantInfo?.selfInfo?.status === CallStatus.CALLING"
class="voice-invite-message">
<Avatar :avatarStyle="avatarStyle"
:src="callParticipantInfo?.allParticipants[0]?.avatarUrl || IMG_DEFAULT_AVATAR" />
<text class="nick-name">
{{ callParticipantInfo?.allParticipants[0]?.name || callParticipantInfo?.allParticipants[0]?.id }}
</text>
</view>
<!-- 通话状态提示 -->
<div class="tips">
<text v-if="showConnectedTip">已接通</text>
<div v-if="!showConnectedTip && callParticipantInfo.selfInfo.status !== CallStatus.CONNECTED">
<text v-if="callParticipantInfo?.selfInfo?.role !== CallRole.CALLER">
{{ mediaType === CallMediaType.AUDIO ? '邀请你进行语音通话' : '邀请你进行视频通话' }}
</text>
<text v-else>等待对方接受</text>
</div>
</div>
<!-- 主叫呼叫阶段按钮 -->
<view class="footer"
v-if="callParticipantInfo?.selfInfo?.status === CallStatus.CALLING && callParticipantInfo?.selfInfo?.role === CallRole.CALLER">
<view class="btn-operate">
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #ED4651;" @click="CallerHangupHandler">
<image v-if="isCallBtnClickable" :src="IMG_HANGUP" mode="aspectFit" />
<image v-else class="img-loading" :src="IMG_LOADING" mode="aspectFit"></image>
</view>
<text>挂断</text>
</view>
</view>
</view>
<!-- 被叫呼叫阶段按钮 -->
<view class="footer"
v-if="callParticipantInfo?.selfInfo?.status === CallStatus.CALLING && callParticipantInfo?.selfInfo?.role !== CallRole.CALLER">
<view class="btn-operate" style="gap: 40px">
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #ED4651;" @click="reject">
<image :src="IMG_HANGUP" mode="aspectFit" />
</view>
<text>挂断</text>
</view>
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #51C271;" @click="acceptHandler">
<image v-if="isAcceptBtnClickable" :src="IMG_ACCEPT" mode="aspectFit" />
<image v-else class="img-loading" :src="IMG_LOADING" mode="aspectFit"></image>
</view>
<text>接听</text>
</view>
</view>
</view>
<!-- 语音通话接听阶段按钮 -->
<view class="footer"
v-if="mediaType === CallMediaType.AUDIO && callParticipantInfo?.selfInfo?.status === CallStatus.CONNECTED">
<view class="btn-operate">
<view class="btn-operate-item" @click="microPhoneHandler">
<view class="call-operate" :style="buttonBgColor(isMicrophoneOpen)">
<image :src="isMicrophoneOpen ? IMG_AUDIO_TRUE : IMG_AUDIO_FALSE" mode="aspectFit"></image>
</view>
<text>{{ isMicrophoneOpen ? '麦克风已开启' : '麦克风已关闭' }}</text>
</view>
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #ED4651;" @click="hangupHandler">
<image mode="aspectFit" v-if="isHangupBtnClickable" :src="IMG_HANGUP" />
<image mode="aspectFit" v-else class="img-loading" :src="IMG_LOADING"></image>
</view>
<text>挂断</text>
</view>
<view class="btn-operate-item" @click="setAudioRoute">
<view class="call-operate" :style="buttonBgColor(isSpeakerOpen)">
<image mode="aspectFit" class="btn-image" :src="isSpeakerOpen ? IMG_SPEAKER_TRUE : IMG_SPEAKER_FALSE">
</image>
</view>
<text>{{ isSpeakerOpen ? '扬声器已开启' : '扬声器已关闭' }}</text>
</view>
</view>
</view>
<!-- 视频通话接听阶段按钮 -->
<view class="footer"
v-if="mediaType === CallMediaType.VIDEO && callParticipantInfo?.selfInfo?.status === CallStatus.CONNECTED">
<view class="btn-operate">
<view class="btn-operate-item" @click="microPhoneHandler">
<view class="call-operate" :style="buttonBgColor(isMicrophoneOpen)">
<image :src="isMicrophoneOpen ? IMG_AUDIO_TRUE : IMG_AUDIO_FALSE" mode="aspectFit"></image>
</view>
<text>{{ isMicrophoneOpen ? '麦克风已开启' : '麦克风已关闭' }}</text>
</view>
<view class="btn-operate-item" @click="setAudioRoute">
<view class="call-operate" :style="buttonBgColor(isSpeakerOpen)">
<image mode="aspectFit" class="btn-image" :src="isSpeakerOpen ? IMG_SPEAKER_TRUE : IMG_SPEAKER_FALSE">
</image>
</view>
<text>{{ isSpeakerOpen ? '扬声器已开启' : '扬声器已关闭' }}</text>
</view>
<view class="btn-operate-item" @click="cameraHandler">
<view class="call-operate" :style="buttonBgColor(isCameraOpen)">
<image mode="aspectFit" class="btn-image" :src="isCameraOpen ? IMG_CAMERA_TRUE : IMG_CAMERA_FALSE"></image>
</view>
<text>{{ isCameraOpen ? '摄像头已开启' : '摄像头已关闭' }}</text>
</view>
</view>
<view class="btn-operate">
<view class="btn-operate-item">
<view class="call-operate" style="background-color: #ED4651;" @click="hangupHandler">
<image v-if="isHangupBtnClickable" :src="IMG_HANGUP" mode="aspectFit" />
<image v-else class="img-loading" :src="IMG_LOADING" mode="aspectFit"></image>
</view>
<view v-if="isCameraOpen" class="switch-camera">
<image :src="IMG_SWITCH_CAMERA" @click="switchCamera" mode="aspectFit"/>
</view>
<text>挂断</text>
</view>
</view>
</view>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import { onUnload } from '@dcloudio/uni-app'
import Avatar from '../Avatar/Avatar.vue';
import TRTCPusher from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPusher.vue';
import TRTCPlayer from '@tencentcloud/trtc-component-uniapp/src/components/TRTCPlayer.vue';
import IMG_HANGUP from '../../assets/call/hangup.svg';
import IMG_ACCEPT from '../../assets/call/accept.svg';
import IMG_AUDIO_TRUE from '../../assets/call/microphone-open.svg';
import IMG_AUDIO_FALSE from '../../assets/call/microphone-close.svg';
import IMG_SPEAKER_TRUE from '../../assets/call/speaker-open.svg';
import IMG_SPEAKER_FALSE from '../../assets/call/speaker-close.svg';
import IMG_CAMERA_TRUE from '../../assets/call/camera-open.svg';
import IMG_CAMERA_FALSE from '../../assets/call/camera-close.svg';
import IMG_SWITCH_CAMERA from '../../assets/call/switch-camera.svg';
import IMG_DEFAULT_AVATAR from '../../assets/base/default-avatar.png';
import IMG_LOADING from '../../assets/call/loading.png';
import { formatDuration } from '../../utils/index';
import { CallMediaType, AudioPlayBackDevice } from '@trtc/call-engine-lite-wx';
import { CallStatus, CallRole } from '../../constants/call';
import { useCallListState, useCallParticipantState, useDeviceState } from '../../index';
const {
inviterId,
mediaType,
duration,
pusherId,
accept,
reject,
hangup,
} = useCallListState();
const { callParticipantInfo } = useCallParticipantState();
const {
currentAudioRoute,
openLocalCamera, closeLocalCamera,
openLocalMicrophone, closeLocalMicrophone,
switchCamera, setAudioRoute,
} = useDeviceState();
const isCallBtnClickable = ref((inviterId.value || '').length > 0 ? true : false);
const isAcceptBtnClickable = ref(true);
const isHangupBtnClickable = ref(true);
const isLocalStreamMain = ref(mediaType.value === CallMediaType.AUDIO ? false : true);
const isMicrophoneOpen = computed(() => callParticipantInfo?.value.selfInfo?.isMicrophoneOpened);
const isCameraOpen = computed(() => callParticipantInfo?.value.selfInfo?.isCameraOpened);
const isSpeakerOpen = computed(() => currentAudioRoute.value !== AudioPlayBackDevice.EAR);
const showConnectedTip = ref(false);
const bigOverlayAvatarStyle = ref({
position: 'absolute',
width: '100%',
height: '100%',
objectFit: 'cover',
zIndex: 1
});
const smallOverlayAvatarStyle = ref({
position: 'absolute',
width: '100%',
height: '100%',
objectFit: 'cover',
zIndex: 99
});
const avatarStyle = ref({
width: '100px',
height: '100px',
borderRadius: '12px',
display: 'block',
margin: '140px auto 15px'
});
function toggleViewSize() {
if (mediaType.value === CallMediaType.VIDEO) {
isLocalStreamMain.value = !isLocalStreamMain.value
}
}
async function cameraHandler() {
if (isCameraOpen.value) {
await closeLocalCamera();
} else {
await openLocalCamera();
}
}
async function microPhoneHandler() {
if (isMicrophoneOpen.value) {
await closeLocalMicrophone();
} else {
await openLocalMicrophone();
}
}
async function CallerHangupHandler() {
try {
if (!isCallBtnClickable.value) return;
await hangup();
} catch (error) {
isCallBtnClickable.value = true;
}
}
async function acceptHandler() {
try {
if (!isAcceptBtnClickable.value) {
console.warn(`previous accept is ongoing, please avoid repeat accept`);
return;
}
isAcceptBtnClickable.value = false;
await accept();
isAcceptBtnClickable.value = true;
} catch (error) {
isAcceptBtnClickable.value = true;
}
}
async function hangupHandler() {
try {
if (!isHangupBtnClickable.value) {
console.warn(`previous hangup is ongoing, please avoid repeat hangup`);
return;
}
isHangupBtnClickable.value = false;
await hangup();
isHangupBtnClickable.value = true;
} catch (error) {
isHangupBtnClickable.value = true;
}
}
const buttonBgColor = computed(() => (isActive: boolean) => {
return isActive ? { backgroundColor: '#FFFFFF', } : {
backgroundColor: '#22262E',
opacity: '0.5'
};
})
watch(() => callParticipantInfo?.value?.selfInfo, (newObj, oldObj) => {
const newStatus = newObj?.status;
if (mediaType.value === CallMediaType.VIDEO) {
isLocalStreamMain.value = newStatus === CallStatus.CALLING
}
if (mediaType.value === CallMediaType.AUDIO) {
isLocalStreamMain.value = false;
}
if (newStatus === CallStatus.CONNECTED && oldObj?.status === CallStatus.CALLING) {
showConnectedTip.value = true;
setTimeout(() => {
showConnectedTip.value = false;
}, 1000);
} else {
showConnectedTip.value = false;
}
if (newStatus === CallStatus.IDLE) {
isHangupBtnClickable.value = false;
isAcceptBtnClickable.value = true;
isHangupBtnClickable.value = true;
}
});
watch(inviterId, (newVal, oldVal) => {
isCallBtnClickable.value = (newVal || '').length > 0 ? true : false;
});
onUnload(() => {
const callStatus = callParticipantInfo?.value?.selfInfo?.status;
const callRole = callParticipantInfo?.value?.selfInfo?.role;
if (callStatus === CallStatus.IDLE) return;
// 已接通:页面卸载(返回、切后台、关闭小程序等)不主动 signaling 挂断,医生端可继续面诊;结束请点「挂断」
if (callStatus === CallStatus.CONNECTED) return;
if (callStatus === CallStatus.CALLING) {
if (callRole === CallRole.CALLER) {
hangup();
} else {
reject();
}
}
});
</script>
<style scoped lang="scss">
.TUICall-container {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
bottom: 0;
left: 0;
margin: 0;
background-color: #ffffff;
.topBar {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
justify-content: center;
width: 100%;
z-index: 10;
.call-duration {
text-align: center;
font-family: PingFang SC;
font-weight: 500;
font-size: 12px;
color: #D5E0F2;
}
}
.big-video {
position: absolute;
width: 100%;
height: 100%;
z-index: 10;
background-color: black;
}
.big-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
.big-overlay-avatar {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}
.big-overlay-mask {
background-color: rgba(0, 0, 0, 0.5);
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 2;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
}
.small-video {
position: absolute;
right: 16px;
top: 100px;
width: 100px;
height: 178px;
z-index: 100;
}
.small-overlay {
position: absolute;
right: 16px;
top: 100px;
width: 100px;
height: 178px;
z-index: 98;
.small-overlay-avatar {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 99;
}
.small-overlay-mask {
position: absolute;
background-color: rgba(0, 0, 0, 0.5);
width: 100%;
height: 100%;
object-fit: cover;
z-index: 100;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
}
.player-audio {
width: 0;
height: 0;
}
.voice-invite-message {
position: fixed;
top: 30%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
text-align: center;
font-family: PingFang SC;
color: #D5E0F2;
font-weight: 500;
z-index: 100;
.nick-name {
font-size: 18px;
text-align: center;
}
}
.tips {
height: 14px;
position: fixed;
top: 70%;
left: 50%;
font-size: 14px;
font-family: PingFang SC;
transform: translate(-50%, -50%);
color: #D5E0F2;
font-weight: 500;
z-index: 100;
}
.footer {
position: absolute;
bottom: 5vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
font-size: 14px;
color: #f0e9e9;
font-weight: 400;
z-index: 100;
.btn-operate {
display: flex;
flex-direction: initial;
text-align: center;
align-items: center;
justify-content: center;
}
.btn-operate-item {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
text {
font-family: PingFang SC;
font-weight: 400;
font-size: 12px;
color: #D5E0F2;
}
.switch-camera {
position: absolute;
right: 98px;
width: 32px;
top: 60%;
margin-left: 40px;
height: 32px;
image {
width: 100%;
height: 100%;
}
}
.call-operate {
width: 60px;
height: 60px;
border-radius: 50%;
margin: 10px 10vw;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
image {
width: 30px;
height: 30px;
background: none;
}
.img-loading {
animation: rotate 1.5s linear infinite;
width: 30px;
height: 30px
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
text {
font-family: PingFang SC;
font-weight: 400;
font-size: 12px;
color: #D5E0F2;
}
}
}
}
}
</style>