音视频通话功能完成(P2P)读秒
已知问题: 文件无法发送
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user