音视频通话功能完成(P2P)

已知问题:
文件无法发送
This commit is contained in:
2025-12-03 15:33:48 +08:00
parent be7bd1279a
commit 9db9b65ea3
15 changed files with 1544 additions and 704 deletions

View File

@@ -1,8 +1,8 @@
<template>
<div
v-show="call.active"
class="fixed z-[999] transition-all duration-300 overflow-hidden bg-gray-900 flex flex-col shadow-2xl border border-gray-700"
:class="[
v-show="call.active"
class="fixed z-[999] transition-all duration-300 overflow-hidden bg-gray-900 flex flex-col shadow-2xl border border-gray-700 font-sans"
:class="[
call.minimized
? 'w-48 h-64 bottom-5 right-5 rounded-xl border-gray-600'
: isMobile
@@ -10,120 +10,134 @@
: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[900px] h-[600px] rounded-2xl',
]"
>
<!-- 顶部功能栏 -->
<!-- 顶部功能栏 (渐变遮罩) -->
<div
class="absolute top-0 w-full p-4 z-20 flex justify-between items-start bg-gradient-to-b from-black/60 to-transparent transition-opacity hover:opacity-100"
:class="{ 'opacity-0': !call.minimized }"
class="absolute top-0 w-full p-4 z-30 flex justify-between items-start bg-gradient-to-b from-black/80 to-transparent transition-opacity hover:opacity-100"
:class="{ 'opacity-0': !call.minimized && !isMobile && call.status === 'connected' }"
>
<div class="text-white text-sm font-bold drop-shadow-md px-2" v-if="!call.minimized">
<div class="text-white text-sm font-bold drop-shadow-md px-2 flex items-center gap-2" v-if="!call.minimized">
<i class="fas" :class="call.type === 'video' ? 'fa-video' : 'fa-phone'"></i>
{{ call.type === 'video' ? '视频通话' : '语音通话' }}
<span>{{ call.type === 'video' ? '视频通话' : '语音通话' }}</span>
<span v-if="call.status === 'connected'" class="text-xs font-normal opacity-80 pl-2 border-l border-white/30">
{{ formatDuration(call.duration) }}
</span>
</div>
<div class="flex gap-3">
<button
class="w-8 h-8 rounded-full bg-black/40 hover:bg-white/20 text-white flex items-center justify-center backdrop-blur transition"
@click="call.minimized = !call.minimized"
<!-- 最小化按钮 -->
<button
class="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center backdrop-blur transition ml-auto"
@click="emit('toggle-minimize')"
title="最小化/还原"
>
<i class="fas" :class="call.minimized ? 'fa-expand' : 'fa-compress'"></i>
</button>
</div>
>
<i class="fas" :class="call.minimized ? 'fa-expand' : 'fa-compress'"></i>
</button>
</div>
<!-- 内容区域 -->
<!-- 内容区域 -->
<div class="flex-1 relative bg-black overflow-hidden flex items-center justify-center">
<!-- 纯语音 UI 连接中状态显示大头像 -->
<!-- 1. 语音/等待状态 UI -->
<div
v-if="call.type === 'audio' || call.status !== 'connected'"
class="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 z-10"
v-if="call.type === 'audio' || call.status !== 'connected'"
class="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 z-10"
>
<!-- 呼叫中/语音通话时的波纹动画 -->
<div
class="w-32 h-32 rounded-full flex items-center justify-center text-5xl font-bold mb-6 shadow-xl transition-all duration-1000 relative"
:class="{
'animate-[ripple_2s_infinite]':
call.status === 'connected' || call.status === 'outgoing',
}"
:style="{ background: target?.color || '#6366f1' }"
>
{{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
<!-- 动态波纹头像 -->
<div class="relative">
<div
class="w-32 h-32 rounded-full flex items-center justify-center text-5xl font-bold mb-6 shadow-2xl z-20 relative border-4 border-gray-800"
:style="{ background: target?.color || '#6366f1' }"
>
{{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
</div>
<!-- 波纹动画 -->
<div v-if="call.status === 'outgoing' || call.status === 'connected'"
class="absolute inset-0 bg-white/20 rounded-full animate-ping z-10"></div>
</div>
<div class="text-2xl font-bold text-gray-100 tracking-wide">
<div class="text-2xl font-bold text-gray-100 tracking-wide mt-2">
{{ target?.remark_name || target?.user?.name || '未知用户' }}
</div>
<div class="text-gray-400 mt-2 font-mono flex items-center gap-2">
<span
v-if="call.status === 'connected'"
class="w-2 h-2 bg-green-500 rounded-full animate-pulse"
></span>
<div class="text-gray-400 mt-2 font-mono flex items-center gap-2 text-sm">
<span v-if="call.status === 'connected'" class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
{{ call.statusText }}
</div>
</div>
<!-- 视频通话 UI -->
<!-- 远程视频流 ( Video 模式且连接成功显示) -->
<!-- 2. 远程视频流 (全屏) -->
<video
ref="remoteVideo"
class="w-full h-full object-cover transition-opacity duration-500 absolute inset-0"
autoplay
playsinline
:class="{
'opacity-0': call.type === 'audio' || call.status !== 'connected',
}"
ref="remoteVideoRef"
class="w-full h-full object-cover transition-opacity duration-500 absolute inset-0 z-0"
autoplay
playsinline
:class="{ 'opacity-0': call.type === 'audio' || call.status !== 'connected' }"
></video>
<!-- 本地视频 (小窗) - Video 模式显示 -->
<video
v-show="call.type === 'video' && !call.minimized"
ref="localVideo"
class="absolute top-5 right-5 w-48 h-36 bg-gray-800 rounded-lg border border-gray-600 object-cover z-20 shadow-2xl transition hover:scale-105"
autoplay
playsinline
muted
></video>
<!-- 3. 本地视频 (画中画) -->
<!-- 优化移动端使用竖屏比例 (w-24 h-32 -> 3:4)PC端使用横屏比例 (w-48 h-36 -> 4:3) -->
<div
v-show="call.type === 'video' && !call.minimized && call.status === 'connected'"
class="absolute z-20 overflow-hidden shadow-2xl transition hover:scale-105 border border-white/20 bg-gray-800"
:class="[
isMobile
? 'top-16 right-4 w-28 h-40 rounded-xl' /* 移动端:右上角,竖长方形 */
: 'bottom-28 right-8 w-56 h-40 rounded-lg' /* PC端右下角横长方形 */
]"
>
<video
ref="localVideoRef"
class="w-full h-full object-cover"
autoplay
playsinline
muted
></video>
<!-- 摄像头关闭提示 -->
<div v-if="call.camOff" class="absolute inset-0 flex items-center justify-center bg-gray-800 text-white/50">
<i class="fas fa-video-slash"></i>
</div>
</div>
</div>
<!-- 底部控制栏 -->
<div
v-if="!call.minimized"
class="h-24 bg-gray-900/90 backdrop-blur border-t border-gray-800 flex items-center justify-center gap-8 shrink-0 pb-4 z-30"
v-if="!call.minimized"
class="h-24 bg-gray-900/80 backdrop-blur-md border-t border-white/10 flex items-center justify-center gap-6 md:gap-10 shrink-0 pb-4 z-30"
>
<!-- 挂断 -->
<button
class="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center text-xl transition transform hover:scale-110 shadow-lg shadow-red-500/30"
@click="endCall"
title="挂断"
class="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center text-xl transition transform active:scale-95 shadow-lg shadow-red-500/30"
@click="emit('end-call')"
title="挂断"
>
<i class="fas fa-phone-slash"></i>
</button>
<!-- 接听 -->
<!-- 接听 (仅来电时显示) -->
<button
v-if="call.status === 'incoming'"
class="w-14 h-14 rounded-full bg-green-500 hover:bg-green-600 text-white flex items-center justify-center text-xl transition transform hover:scale-110 shadow-lg shadow-green-500/30"
@click="acceptCall"
title="接听"
v-if="call.status === 'incoming'"
class="w-14 h-14 rounded-full bg-green-500 hover:bg-green-600 text-white flex items-center justify-center text-xl transition transform active:scale-95 shadow-lg shadow-green-500/30 animate-pulse"
@click="emit('accept-call')"
title="接听"
>
<i class="fas fa-phone"></i>
</button>
<!-- 通话中功能 -->
<!-- 通话中功能按钮 -->
<template v-if="call.status === 'connected'">
<button
class="w-12 h-12 rounded-full bg-gray-700 hover:bg-gray-600 text-white transition flex items-center justify-center"
@click="toggleMute"
:class="{ 'bg-white text-black': call.muted }"
title="静音"
class="w-12 h-12 rounded-full bg-gray-700/80 hover:bg-gray-600 text-white transition flex items-center justify-center backdrop-blur"
@click="emit('toggle-mute')"
:class="{ 'bg-white !text-black': call.muted }"
title="静音"
>
<i class="fas" :class="call.muted ? 'fa-microphone-slash' : 'fa-microphone'"></i>
</button>
<!-- 仅视频模式显示关闭摄像头 -->
<button
v-if="call.type === 'video'"
class="w-12 h-12 rounded-full bg-gray-700 hover:bg-gray-600 text-white transition flex items-center justify-center"
@click="toggleCamera"
:class="{ 'bg-white text-black': call.camOff }"
title="关摄像头"
v-if="call.type === 'video'"
class="w-12 h-12 rounded-full bg-gray-700/80 hover:bg-gray-600 text-white transition flex items-center justify-center backdrop-blur"
@click="emit('toggle-camera')"
:class="{ 'bg-white !text-black': call.camOff }"
title="关摄像头"
>
<i class="fas" :class="call.camOff ? 'fa-video-slash' : 'fa-video'"></i>
</button>
@@ -133,7 +147,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch, onMounted } from 'vue'
import type { Contact } from '@/types/api'
interface Props {
@@ -141,16 +155,20 @@ interface Props {
active: boolean
minimized: boolean
type: 'audio' | 'video'
status: 'idle' | 'outgoing' | 'incoming' | 'connected'
status: string
statusText: string
muted: boolean
camOff: boolean
duration: number
}
target: Contact | null
isMobile: boolean
// 接收 MediaStream 对象,而不是 DOM 元素
localStream: MediaStream | null
remoteStream: MediaStream | null
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{
'end-call': []
@@ -160,28 +178,36 @@ const emit = defineEmits<{
'toggle-minimize': []
}>()
const remoteVideo = ref<HTMLVideoElement>()
const localVideo = ref<HTMLVideoElement>()
const localVideoRef = ref<HTMLVideoElement | null>(null)
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
function endCall() {
emit('end-call')
// 格式化时间辅助函数
const formatDuration = (seconds: number) => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
const s = (seconds % 60).toString().padStart(2, '0')
return `${m}:${s}`
}
function acceptCall() {
emit('accept-call')
}
// 自动绑定流到 Video 元素 (解决本地视频异常的核心)
watch(() => props.localStream, (newStream) => {
if (localVideoRef.value && newStream) {
localVideoRef.value.srcObject = newStream
}
}, { immediate: true, flush: 'post' })
function toggleMute() {
emit('toggle-mute')
}
watch(() => props.remoteStream, (newStream) => {
if (remoteVideoRef.value && newStream) {
remoteVideoRef.value.srcObject = newStream
}
}, { immediate: true, flush: 'post' })
function toggleCamera() {
emit('toggle-camera')
}
defineExpose({
remoteVideo,
localVideo,
// 确保在组件挂载或 Video 元素出现时也能绑定
watch([localVideoRef, remoteVideoRef], () => {
if (localVideoRef.value && props.localStream) {
localVideoRef.value.srcObject = props.localStream
}
if (remoteVideoRef.value && props.remoteStream) {
remoteVideoRef.value.srcObject = props.remoteStream
}
})
</script>