582 lines
18 KiB
Vue
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>
|