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

已知问题:
文件无法发送
This commit is contained in:
2025-12-03 15:58:57 +08:00
parent 9db9b65ea3
commit 3a98cee2b7
3 changed files with 619 additions and 289 deletions

View File

@@ -1,31 +1,29 @@
<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 font-sans"
:class="[
call.minimized
? 'w-48 h-64 bottom-5 right-5 rounded-xl border-gray-600'
: isMobile
? 'inset-0 w-full h-full rounded-none'
: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[900px] h-[600px] rounded-2xl',
]"
ref="windowRef"
class="fixed z-[999] transition-shadow duration-300 overflow-hidden bg-gray-900 flex flex-col shadow-2xl border border-gray-700 font-sans select-none"
:class="windowClass"
:style="dragStyle"
>
<!-- 顶部功能栏 (渐变遮罩) -->
<!-- 顶部功能栏 (作为拖拽手柄) -->
<div
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' }"
class="absolute top-0 w-full p-4 z-30 flex justify-between items-start bg-gradient-to-b from-black/80 to-transparent cursor-move"
@mousedown="startDrag"
>
<div class="text-white text-sm font-bold drop-shadow-md px-2 flex items-center gap-2" v-if="!call.minimized">
<div class="text-white text-sm font-bold drop-shadow-md px-2 flex items-center gap-2 pointer-events-none" v-if="!call.minimized">
<i class="fas" :class="call.type === 'video' ? 'fa-video' : 'fa-phone'"></i>
<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">
<!-- 计时器 -->
<span v-if="call.status === 'connected'" class="text-xs font-normal opacity-80 pl-2 border-l border-white/30 font-mono tracking-wider">
{{ formatDuration(call.duration) }}
</span>
</div>
<!-- 最小化按钮 -->
<!-- 最小化按钮 (阻止冒泡以免触发拖拽) -->
<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"
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 cursor-pointer"
@mousedown.stop
@click="emit('toggle-minimize')"
title="最小化/还原"
>
@@ -36,9 +34,9 @@
<!-- 主内容区域 -->
<div class="flex-1 relative bg-black overflow-hidden flex items-center justify-center">
<!-- 1. 语音/等待状态 UI -->
<!-- A. 头像背景层:在语音模式、等待连接、或对方关闭摄像头时显示 -->
<div
v-if="call.type === 'audio' || call.status !== 'connected'"
v-if="call.type === 'audio' || call.status !== 'connected' || call.remoteCamOff"
class="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 z-10"
>
<!-- 动态波纹头像 -->
@@ -57,30 +55,40 @@
<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 text-sm">
<span v-if="call.status === 'connected'" class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
{{ call.statusText }}
<span v-if="call.remoteCamOff && call.status === 'connected'">对方已关闭摄像头</span>
<span v-else>{{ call.statusText }}</span>
</div>
<!-- 纯语音模式下的计时器 -->
<div v-if="call.status === 'connected'" class="mt-4 text-3xl font-mono text-white/90 font-light">
{{ formatDuration(call.duration) }}
</div>
</div>
<!-- 2. 远程视频流 (全屏) -->
<!-- B. 远程视频流 (全屏) -->
<!-- 只有在视频模式、已连接、且对方开启摄像头时显示 -->
<video
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' }"
:class="{ 'opacity-0': call.type === 'audio' || call.status !== 'connected' || call.remoteCamOff }"
></video>
<!-- 3. 本地视频流 (画中画) -->
<!-- 优化:移动端使用竖屏比例 (w-24 h-32 -> 3:4),PC端使用横屏比例 (w-48 h-36 -> 4:3) -->
<!-- C. 本地视频流 (画中画) -->
<!-- 需求:发起视频通话时即使未接通也要看到自己 -->
<!-- 显示条件:是视频通话 && 未最小化 && (状态是已连接 或 正在呼叫/拨号) -->
<div
v-show="call.type === 'video' && !call.minimized && call.status === 'connected'"
v-show="call.type === 'video' && !call.minimized && (call.status === 'connected' || call.status === 'outgoing')"
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端:右下角,横长方形 */
? 'top-16 right-4 w-28 h-40 rounded-xl'
: 'bottom-28 right-8 w-56 h-40 rounded-lg'
]"
>
<video
@@ -102,7 +110,6 @@
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 active:scale-95 shadow-lg shadow-red-500/30"
@click="emit('end-call')"
@@ -111,7 +118,6 @@
<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 active:scale-95 shadow-lg shadow-green-500/30 animate-pulse"
@@ -121,7 +127,6 @@
<i class="fas fa-phone"></i>
</button>
<!-- 通话中功能按钮 -->
<template v-if="call.status === 'connected'">
<button
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"
@@ -147,7 +152,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ref, watch, computed, onMounted, onUnmounted, reactive } from 'vue'
import type { Contact } from '@/types/api'
interface Props {
@@ -159,11 +164,11 @@ interface Props {
statusText: string
muted: boolean
camOff: boolean
remoteCamOff: boolean
duration: number
}
target: Contact | null
isMobile: boolean
// 接收 MediaStream 对象,而不是 DOM 元素
localStream: MediaStream | null
remoteStream: MediaStream | null
}
@@ -180,34 +185,107 @@ const emit = defineEmits<{
const localVideoRef = ref<HTMLVideoElement | null>(null)
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
const windowRef = ref<HTMLElement | null>(null)
// 格式化时间辅助函数
// --- 窗口类名逻辑 ---
const windowClass = computed(() => {
if (props.call.minimized) {
// 最小化时固定在右下角,不应用拖拽位置(或者您可以选择最小化也能拖拽,这里简化为固定)
return 'w-48 h-64 bottom-5 right-5 rounded-xl border-gray-600 fixed'
}
if (props.isMobile) {
return 'inset-0 w-full h-full rounded-none fixed'
}
// 正常模式,移除 top/left/transform 的 Tailwind 类,完全由 style 控制位置
return 'w-[900px] h-[600px] rounded-2xl'
})
// --- 拖拽逻辑 ---
const drag = reactive({
isDragging: false,
startX: 0,
startY: 0,
left: 0,
top: 0,
initialized: false
})
// 居中初始化
onMounted(() => {
if (!props.isMobile && !props.call.minimized) {
drag.left = window.innerWidth / 2 - 450
drag.top = window.innerHeight / 2 - 300
drag.initialized = true
}
})
// 监听窗口大小变化以修正边界
window.addEventListener('resize', () => {
if (drag.initialized) clampPosition()
})
const dragStyle = computed(() => {
if (props.isMobile || props.call.minimized || !drag.initialized) return {}
return {
left: `${drag.left}px`,
top: `${drag.top}px`,
position: 'fixed',
transform: 'none' // 覆盖掉 Tailwind 可能的 transform
}
})
function clampPosition() {
const w = 900
const h = 600
const maxLeft = window.innerWidth - w
const maxTop = window.innerHeight - h
drag.left = Math.max(0, Math.min(drag.left, maxLeft))
drag.top = Math.max(0, Math.min(drag.top, maxTop))
}
function startDrag(e: MouseEvent) {
if (props.isMobile || props.call.minimized) return
drag.isDragging = true
drag.startX = e.clientX - drag.left
drag.startY = e.clientY - drag.top
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}
function onDrag(e: MouseEvent) {
if (!drag.isDragging) return
drag.left = e.clientX - drag.startX
drag.top = e.clientY - drag.startY
clampPosition()
}
function stopDrag() {
drag.isDragging = false
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
// --- 格式化时间 ---
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}`
const h = Math.floor(seconds / 3600)
return h > 0 ? `${h.toString().padStart(2, '0')}:${m}:${s}` : `${m}:${s}`
}
// 自动绑定流到 Video 元素 (解决本地视频异常的核心)
// --- 自动绑定流 ---
watch(() => props.localStream, (newStream) => {
if (localVideoRef.value && newStream) {
localVideoRef.value.srcObject = newStream
}
if (localVideoRef.value && newStream) localVideoRef.value.srcObject = newStream
}, { immediate: true, flush: 'post' })
watch(() => props.remoteStream, (newStream) => {
if (remoteVideoRef.value && newStream) {
remoteVideoRef.value.srcObject = newStream
}
if (remoteVideoRef.value && newStream) remoteVideoRef.value.srcObject = newStream
}, { immediate: true, flush: 'post' })
// 确保在组件挂载或 Video 元素出现时也能绑定
// 重新激活或状态变化时确保绑定
watch([localVideoRef, remoteVideoRef], () => {
if (localVideoRef.value && props.localStream) {
localVideoRef.value.srcObject = props.localStream
}
if (remoteVideoRef.value && props.remoteStream) {
remoteVideoRef.value.srcObject = props.remoteStream
}
if (localVideoRef.value && props.localStream) localVideoRef.value.srcObject = props.localStream
if (remoteVideoRef.value && props.remoteStream) remoteVideoRef.value.srcObject = props.remoteStream
})
</script>