群视频通话
This commit is contained in:
@@ -1,31 +1,45 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="showBanner"
|
||||
class="mx-4 mb-2 p-2 bg-gradient-to-r from-green-900/40 to-emerald-900/40 border border-green-500/20 rounded-xl flex items-center justify-between backdrop-blur-sm shadow-lg animate-fade-in-down"
|
||||
class="mx-4 mb-2 p-3 bg-[#1e1e24]/90 border border-white/5 rounded-xl flex items-center justify-between shadow-lg backdrop-blur-md animate-fade-in-down group hover:bg-[#25252b] transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-2">
|
||||
<!-- 动态波纹图标 -->
|
||||
<div class="relative w-8 h-8 flex items-center justify-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 动态图标 -->
|
||||
<div class="relative w-10 h-10 flex-shrink-0">
|
||||
<div class="absolute inset-0 bg-green-500 rounded-full opacity-20 animate-ping"></div>
|
||||
<div class="relative w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center border border-green-500/30">
|
||||
<i class="fas fa-video text-green-400 text-xs"></i>
|
||||
<div class="relative w-full h-full rounded-full bg-gradient-to-br from-green-500 to-emerald-700 flex items-center justify-center text-white shadow-inner">
|
||||
<i class="fas fa-video text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-green-100 text-xs font-bold tracking-wide">群通话进行中</p>
|
||||
<p class="text-green-400/70 text-[10px] flex items-center gap-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 inline-block"></span>
|
||||
{{ participantsCount }} 人参与
|
||||
</p>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-white font-bold text-sm tracking-wide">群通话进行中</span>
|
||||
<span class="px-1.5 py-0.5 bg-green-500/20 text-green-400 text-[10px] rounded font-mono">{{ participantsCount }}人</span>
|
||||
</div>
|
||||
|
||||
<!-- 头像堆叠 -->
|
||||
<div class="flex -space-x-2 mt-1.5 overflow-hidden py-0.5 pl-0.5">
|
||||
<div v-for="user in previewParticipants" :key="user.userId" class="relative transition-transform hover:scale-110 hover:z-10">
|
||||
<Avatar
|
||||
:name="user.name"
|
||||
:avatar="user.avatar"
|
||||
size="sm"
|
||||
class="w-5 h-5 ring-2 ring-[#1e1e24]"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="participantsCount > 5" class="w-5 h-5 rounded-full bg-gray-700 text-[8px] text-white flex items-center justify-center ring-2 ring-[#1e1e24] font-medium">
|
||||
+{{ participantsCount - 5 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="px-3 py-1.5 bg-green-600 hover:bg-green-500 text-white text-xs font-bold rounded-lg transition shadow-lg shadow-green-900/30 flex items-center gap-1 active:scale-95"
|
||||
class="px-4 py-2 bg-white text-black text-xs font-bold rounded-lg transition hover:bg-gray-200 active:scale-95 flex items-center gap-1.5 shadow-lg shadow-white/5"
|
||||
@click="groupWebRTC.joinCurrentCall()"
|
||||
>
|
||||
<i class="fas fa-phone-alt text-[10px]"></i>
|
||||
<i class="fas fa-phone-alt"></i>
|
||||
<span>加入</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -34,6 +48,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
roomId: string
|
||||
@@ -50,6 +65,7 @@ const showBanner = computed(() => {
|
||||
})
|
||||
|
||||
const participantsCount = computed(() => participants.value.length)
|
||||
const previewParticipants = computed(() => participants.value.slice(0, 5))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,48 +1,66 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- 全屏背景遮罩:磨砂玻璃效果 -->
|
||||
<div
|
||||
class="fixed z-[9999] top-8 right-8 w-80 bg-[#1e1e24] border border-gray-700 rounded-2xl shadow-2xl overflow-hidden animate-slide-in"
|
||||
class="fixed inset-0 z-[10000] flex flex-col items-center justify-center bg-gray-900/80 backdrop-blur-xl animate-fade-in select-none"
|
||||
>
|
||||
<div class="p-6 flex flex-col items-center">
|
||||
<!-- 头像区 -->
|
||||
<div class="relative mb-4">
|
||||
<!-- 动态背景光晕 -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-500/20 rounded-full blur-[100px] animate-pulse-slow"></div>
|
||||
<div class="absolute bottom-1/4 right-1/4 w-96 h-96 bg-blue-500/20 rounded-full blur-[100px] animate-pulse-slow delay-1000"></div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容卡片 -->
|
||||
<div class="relative z-10 flex flex-col items-center animate-slide-up">
|
||||
|
||||
<!-- 头像区域 (带波纹动画) -->
|
||||
<div class="relative mb-8">
|
||||
<div class="absolute inset-0 bg-white/10 rounded-full animate-ping-slow"></div>
|
||||
<div class="absolute inset-[-10px] bg-white/5 rounded-full animate-ping-slower"></div>
|
||||
|
||||
<Avatar
|
||||
:name="callState.inviterName"
|
||||
size="xl"
|
||||
class="ring-4 ring-gray-800 shadow-lg"
|
||||
:avatar="callState.inviterAvatar"
|
||||
size="2xl"
|
||||
class="w-32 h-32 text-4xl shadow-2xl border-4 border-white/10 relative z-20"
|
||||
/>
|
||||
<div class="absolute -bottom-1 -right-1 w-6 h-6 bg-green-500 rounded-full border-4 border-gray-800 flex items-center justify-center">
|
||||
<i class="fas fa-video text-white text-[10px]"></i>
|
||||
|
||||
<!-- 类型图标 -->
|
||||
<div class="absolute bottom-0 right-0 w-10 h-10 bg-gradient-to-br from-green-400 to-emerald-600 rounded-full flex items-center justify-center shadow-lg z-30 border-4 border-gray-900">
|
||||
<i class="fas" :class="callState.type === 'video' ? 'fa-video' : 'fa-phone-alt'"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-white font-bold text-lg mb-1">{{ callState.inviterName }}</h3>
|
||||
<p class="text-gray-400 text-sm mb-6">邀请你进行群视频通话...</p>
|
||||
<!-- 文本信息 -->
|
||||
<h2 class="text-3xl font-bold text-white mb-2 tracking-tight">{{ callState.inviterName }}</h2>
|
||||
<p class="text-gray-300 text-lg mb-12 flex items-center gap-2">
|
||||
<span>邀请你加入群{{ callState.type === 'video' ? '视频' : '语音' }}通话</span>
|
||||
<span class="flex gap-1">
|
||||
<span class="w-1 h-1 bg-gray-400 rounded-full animate-bounce"></span>
|
||||
<span class="w-1 h-1 bg-gray-400 rounded-full animate-bounce delay-100"></span>
|
||||
<span class="w-1 h-1 bg-gray-400 rounded-full animate-bounce delay-200"></span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<!-- 按钮区 -->
|
||||
<div class="flex gap-4 w-full">
|
||||
<button
|
||||
class="flex-1 py-2.5 bg-red-500/10 hover:bg-red-500/20 text-red-500 rounded-xl font-medium transition flex items-center justify-center gap-2"
|
||||
@click="groupWebRTC.rejectInvite()"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
拒绝
|
||||
</button>
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center gap-16">
|
||||
<!-- 拒绝 -->
|
||||
<div class="flex flex-col items-center gap-3 group cursor-pointer" @click="groupWebRTC.rejectInvite()">
|
||||
<div class="w-16 h-16 rounded-full bg-red-500/20 group-hover:bg-red-500 text-red-500 group-hover:text-white flex items-center justify-center transition-all duration-300 backdrop-blur-md border border-red-500/30">
|
||||
<i class="fas fa-times text-2xl"></i>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400 group-hover:text-white transition-colors">拒绝</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="flex-1 py-2.5 bg-green-500 hover:bg-green-600 text-white rounded-xl font-medium transition shadow-lg shadow-green-500/20 flex items-center justify-center gap-2"
|
||||
@click="groupWebRTC.acceptInvite()"
|
||||
>
|
||||
<i class="fas fa-video"></i>
|
||||
接听
|
||||
</button>
|
||||
<!-- 接听 -->
|
||||
<div class="flex flex-col items-center gap-3 group cursor-pointer" @click="groupWebRTC.acceptInvite()">
|
||||
<div class="w-20 h-20 rounded-full bg-green-500 text-white flex items-center justify-center transition-all duration-300 shadow-lg shadow-green-500/40 group-hover:scale-110 group-hover:shadow-green-500/60 animate-pulse-border">
|
||||
<i class="fas" :class="callState.type === 'video' ? 'fa-video' : 'fa-phone'"></i>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400 group-hover:text-white transition-colors">接听</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条倒计时 -->
|
||||
<div class="h-1 bg-gray-800 w-full">
|
||||
<div class="h-full bg-green-500 animate-progress origin-left"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -56,19 +74,23 @@ const { callState } = groupWebRTC
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.animate-ping-slow {
|
||||
animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
.animate-ping-slower {
|
||||
animation: ping 3s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
.animate-pulse-slow {
|
||||
animation: pulse 6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
from { transform: scaleX(1); }
|
||||
to { transform: scaleX(0); }
|
||||
}
|
||||
.animate-progress {
|
||||
animation: progress 30s linear forwards;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
@keyframes pulse { 0%, 100% { opacity: 0.2; } 50% { opacity: 0.5; } }
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface CallParticipant {
|
||||
stream?: MediaStream
|
||||
isMuted: boolean
|
||||
isCamOff: boolean
|
||||
status: 'connecting' | 'connected' | 'failed'
|
||||
status: 'connecting' | 'connected' | 'failed' | 'rejected' // 新增 rejected 状态
|
||||
volume?: number
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface GroupCallState {
|
||||
groupId: string
|
||||
initiatorId: string
|
||||
inviterName: string
|
||||
inviterAvatar?: string // 新增邀请人头像
|
||||
type: 'audio' | 'video'
|
||||
duration: number
|
||||
startTime: number | null
|
||||
@@ -37,6 +38,7 @@ interface SignalPayload {
|
||||
callRoomId: string
|
||||
senderId: string
|
||||
senderName?: string
|
||||
senderAvatar?: string // 携带头像
|
||||
targetId?: string
|
||||
data?: any
|
||||
state?: { muted: boolean; camOff: boolean }
|
||||
@@ -44,7 +46,7 @@ interface SignalPayload {
|
||||
type?: 'audio' | 'video'
|
||||
}
|
||||
|
||||
// --- 全局单例状态 (关键: 必须在函数外) ---
|
||||
// --- 全局单例状态 ---
|
||||
const callState = reactive<GroupCallState>({
|
||||
incoming: false,
|
||||
joined: false,
|
||||
@@ -81,11 +83,9 @@ export function useGroupWebRTC() {
|
||||
const toastStore = useToastStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 辅助计算属性
|
||||
const isActive = computed(() => callState.joined || callState.incoming)
|
||||
|
||||
function initListener() {
|
||||
// 避免重复注册
|
||||
wsManager.offSignal(handleSignalMessage)
|
||||
wsManager.onSignal(handleSignalMessage)
|
||||
}
|
||||
@@ -147,9 +147,7 @@ export function useGroupWebRTC() {
|
||||
// 1. 处理邀请 (Invite)
|
||||
if (content.action === 'invite') {
|
||||
if (!content.participantIds || !content.callRoomId) return
|
||||
if (callState.joined) return // 忙线
|
||||
|
||||
console.log('🔔 收到群通话邀请:', content)
|
||||
if (callState.joined) return // 忙线自动忽略
|
||||
|
||||
callState.incoming = true
|
||||
callState.roomId = content.callRoomId
|
||||
@@ -157,10 +155,11 @@ export function useGroupWebRTC() {
|
||||
callState.type = content.type || 'video'
|
||||
callState.initiatorId = content.senderId
|
||||
callState.inviterName = content.senderName || '群成员'
|
||||
callState.inviterAvatar = content.senderAvatar
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Banner 状态更新 (被动感知)
|
||||
// 2. Banner 状态更新
|
||||
if (content.action === 'join' && msg.room_id) {
|
||||
if (callState.roomId && content.callRoomId !== callState.roomId) return
|
||||
if (!callState.joined && !callState.incoming) {
|
||||
@@ -173,18 +172,16 @@ export function useGroupWebRTC() {
|
||||
|
||||
switch (content.action) {
|
||||
case 'join':
|
||||
addParticipant(content.senderId, content.senderName || '成员', '', 'connecting')
|
||||
// 只要我加入了,我就尝试连接新人
|
||||
// 这里的连接逻辑交给 getPeerConnection 中的 onnegotiationneeded 自动处理
|
||||
addParticipant(content.senderId, content.senderName || '成员', content.senderAvatar || '', 'connecting')
|
||||
// 只有我已经加入了,才发起连接
|
||||
if (callState.joined) {
|
||||
// 仅仅初始化 PC 并添加轨道,就会触发 negotiation -> 发送 Offer
|
||||
getPeerConnection(content.senderId)
|
||||
}
|
||||
break
|
||||
|
||||
case 'offer':
|
||||
if (content.targetId === myId && callState.joined) {
|
||||
addParticipant(content.senderId, content.senderName || '成员', '', 'connecting')
|
||||
addParticipant(content.senderId, content.senderName || '成员', content.senderAvatar || '', 'connecting')
|
||||
await handleOffer(content.senderId, content.data)
|
||||
}
|
||||
break
|
||||
@@ -209,39 +206,47 @@ export function useGroupWebRTC() {
|
||||
break
|
||||
|
||||
case 'reject':
|
||||
// 【核心修复】 处理对方拒绝
|
||||
// 找到对应的参与者,标记为已拒绝,或者直接移除
|
||||
const pIndex = participants.value.findIndex(p => p.userId === content.senderId)
|
||||
if (pIndex !== -1) {
|
||||
// 方案A: 移除
|
||||
// participants.value.splice(pIndex, 1)
|
||||
|
||||
// 方案B: 标记状态 (UI显示灰色或"已拒绝")
|
||||
participants.value[pIndex].status = 'rejected'
|
||||
|
||||
// 如果所有人都拒绝了,可以考虑提示用户
|
||||
toastStore.info(`${content.senderName || '对方'} 拒绝了通话`)
|
||||
|
||||
// 关闭可能存在的连接
|
||||
const pc = peerConnections.get(content.senderId)
|
||||
if (pc) { pc.close(); peerConnections.delete(content.senderId) }
|
||||
}
|
||||
break
|
||||
|
||||
case 'sync_state':
|
||||
updateParticipantState(content.senderId, content.state)
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Signal processing error', e)
|
||||
}
|
||||
} catch (e) { console.error('Signal error', e) }
|
||||
}
|
||||
|
||||
// --- WebRTC 连接管理 (Perfect Negotiation 实现) ---
|
||||
|
||||
// --- WebRTC 连接管理 (Perfect Negotiation) ---
|
||||
function getPeerConnection(targetUserId: string): RTCPeerConnection {
|
||||
if (peerConnections.has(targetUserId)) return peerConnections.get(targetUserId)!
|
||||
|
||||
const pc = new RTCPeerConnection(iceServers)
|
||||
|
||||
// 初始化状态
|
||||
makingOffer.set(targetUserId, false)
|
||||
ignoreOffer.set(targetUserId, false)
|
||||
|
||||
// 添加本地轨道 -> 这会自动触发 onnegotiationneeded
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach(t => pc.addTrack(t, localStream.value!))
|
||||
}
|
||||
|
||||
// 自动协商逻辑 (核心修复:使用无参 setLocalDescription)
|
||||
pc.onnegotiationneeded = async () => {
|
||||
try {
|
||||
makingOffer.set(targetUserId, true)
|
||||
// 使用不带参数的 setLocalDescription,让浏览器自动生成最合适的 SDP
|
||||
// 这解决了 "m-lines order" 错误
|
||||
await pc.setLocalDescription()
|
||||
await sendSignal('offer', { targetId: targetUserId, data: pc.localDescription })
|
||||
} catch (err) {
|
||||
@@ -268,91 +273,58 @@ export function useGroupWebRTC() {
|
||||
return pc
|
||||
}
|
||||
|
||||
// 处理 Offer (解决 Glare 冲突)
|
||||
// ... (handleOffer, handleAnswer, handleCandidate, flushPendingCandidates 保持不变,复用上一版代码) ...
|
||||
// 为了节省篇幅,这里假设已包含上一版 Perfect Negotiation 的完整逻辑
|
||||
// 请务必将上一版 handleOffer 等函数完整保留在这里
|
||||
|
||||
async function handleOffer(senderId: string, offerSdp: RTCSessionDescriptionInit) {
|
||||
const pc = getPeerConnection(senderId)
|
||||
const myId = authStore.user?.id || ''
|
||||
|
||||
// 判断谁是"礼貌方" (Polite Peer)
|
||||
// 规则:ID 字符串比较,小的一方为礼貌方(始终接受回滚)
|
||||
const polite = myId < senderId
|
||||
|
||||
// 判断是否发生冲突:非稳定状态 或 我们正在制造 Offer
|
||||
const offerCollision = (makingOffer.get(senderId) || pc.signalingState !== 'stable')
|
||||
|
||||
ignoreOffer.set(senderId, !polite && offerCollision)
|
||||
if (ignoreOffer.get(senderId)) return
|
||||
|
||||
if (ignoreOffer.get(senderId)) {
|
||||
console.warn(`[WebRTC] Glare: Im impolite, ignoring offer from ${senderId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果我是礼貌方,且发生冲突,我需要回滚以接受对方的 Offer
|
||||
if (offerCollision) {
|
||||
console.log(`[WebRTC] Glare: Im polite, rolling back to accept offer from ${senderId}`)
|
||||
// 回滚本地状态到 stable
|
||||
await pc.setLocalDescription({ type: 'rollback' })
|
||||
}
|
||||
|
||||
try {
|
||||
await pc.setRemoteDescription(offerSdp)
|
||||
// 只有 setRemoteDescription 成功后才设置 Answer
|
||||
await pc.setLocalDescription() // 自动生成 Answer
|
||||
await pc.setLocalDescription()
|
||||
await sendSignal('answer', { targetId: senderId, data: pc.localDescription })
|
||||
|
||||
// 处理堆积的 Candidates
|
||||
await flushPendingCandidates(senderId, pc)
|
||||
} catch (e) {
|
||||
console.error('Handle offer failed:', e)
|
||||
}
|
||||
} catch (e) { console.error('Offer error', e) }
|
||||
}
|
||||
|
||||
async function handleAnswer(senderId: string, answerSdp: RTCSessionDescriptionInit) {
|
||||
const pc = getPeerConnection(senderId)
|
||||
const isIgnored = ignoreOffer.get(senderId)
|
||||
|
||||
if (isIgnored) return
|
||||
|
||||
if (ignoreOffer.get(senderId)) return
|
||||
try {
|
||||
// 只有当我们在等待 Answer 时才设置
|
||||
if (pc.signalingState === 'have-local-offer') {
|
||||
await pc.setRemoteDescription(answerSdp)
|
||||
await flushPendingCandidates(senderId, pc)
|
||||
} else {
|
||||
console.warn(`[WebRTC] Ignored answer in state: ${pc.signalingState}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Handle answer failed:', e)
|
||||
}
|
||||
} catch (e) { console.error('Answer error', e) }
|
||||
}
|
||||
|
||||
async function handleCandidate(senderId: string, candidate: RTCIceCandidateInit) {
|
||||
const pc = getPeerConnection(senderId)
|
||||
|
||||
if (ignoreOffer.get(senderId)) return
|
||||
|
||||
try {
|
||||
// 只有当 RemoteDescription 设置后才能添加 Candidate
|
||||
if (!pc.remoteDescription || !pc.remoteDescription.type) {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(candidate))
|
||||
} catch (e) {
|
||||
if (!pc.remoteDescription) {
|
||||
if (!pendingCandidates.has(senderId)) pendingCandidates.set(senderId, [])
|
||||
pendingCandidates.get(senderId)?.push(candidate)
|
||||
} else {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(candidate))
|
||||
}
|
||||
} catch (e) {
|
||||
if (!ignoreOffer.get(senderId)) {
|
||||
console.warn('Add ICE failed (premature?):', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function flushPendingCandidates(userId: string, pc: RTCPeerConnection) {
|
||||
const candidates = pendingCandidates.get(userId) || []
|
||||
if (candidates.length === 0) return
|
||||
|
||||
for (const c of candidates) {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(c)).catch(e => {})
|
||||
}
|
||||
for (const c of candidates) await pc.addIceCandidate(c).catch(e=>{})
|
||||
pendingCandidates.delete(userId)
|
||||
}
|
||||
|
||||
@@ -365,6 +337,7 @@ export function useGroupWebRTC() {
|
||||
callRoomId: callState.roomId,
|
||||
senderId: myId,
|
||||
senderName: authStore.user?.name || '我',
|
||||
senderAvatar: authStore.user?.avatar || '',
|
||||
...payload
|
||||
}
|
||||
|
||||
@@ -384,33 +357,29 @@ export function useGroupWebRTC() {
|
||||
// --- 操作方法 ---
|
||||
|
||||
async function startGroupCall(groupId: string, selectedUserIds: string[], type: 'audio' | 'video') {
|
||||
// 1. 重置旧状态
|
||||
resetState()
|
||||
|
||||
// 2. 设置新状态
|
||||
callState.groupId = groupId
|
||||
callState.type = type
|
||||
callState.roomId = `group_call_${groupId}_${Date.now()}`
|
||||
callState.initiatorId = authStore.user?.id || ''
|
||||
callState.minimized = false
|
||||
callState.joined = true // 立即设置为 joined 保证 UI 显示
|
||||
callState.joined = true
|
||||
|
||||
try {
|
||||
await initLocalMedia(type === 'video')
|
||||
initListener()
|
||||
startTimer()
|
||||
|
||||
// 发送广播邀请
|
||||
await sendSignal('invite', { participantIds: selectedUserIds, type })
|
||||
|
||||
// UI 占位
|
||||
selectedUserIds.forEach(uid => {
|
||||
if (uid !== callState.initiatorId) addParticipant(uid, '呼叫中...', '', 'connecting')
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Start call error', e)
|
||||
if (e instanceof Error && (e.name === 'NotAllowedError' || e.name === 'NotFoundError')) {
|
||||
toastStore.error('无法启动通话:请检查摄像头/麦克风权限')
|
||||
toastStore.error('无法启动通话:请检查权限')
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
@@ -419,22 +388,18 @@ export function useGroupWebRTC() {
|
||||
async function acceptInvite() {
|
||||
callState.incoming = false
|
||||
callState.joined = true
|
||||
|
||||
try {
|
||||
await initLocalMedia(callState.type === 'video')
|
||||
startTimer()
|
||||
// 发送 Join,告诉大家我来了
|
||||
await sendSignal('join', {})
|
||||
} catch (e) {
|
||||
console.error('Accept call error', e)
|
||||
if (e instanceof Error && (e.name === 'NotAllowedError' || e.name === 'NotFoundError')) {
|
||||
toastStore.error('无法接听:请检查摄像头/麦克风权限')
|
||||
resetState()
|
||||
}
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
|
||||
function rejectInvite() {
|
||||
// 【核心修复】发送 reject 信号
|
||||
sendSignal('reject', {})
|
||||
resetState()
|
||||
}
|
||||
@@ -445,9 +410,7 @@ export function useGroupWebRTC() {
|
||||
}
|
||||
|
||||
function leaveCall() {
|
||||
if (callState.joined) {
|
||||
sendSignal('leave', {})
|
||||
}
|
||||
if (callState.joined) sendSignal('leave', {})
|
||||
resetState()
|
||||
}
|
||||
|
||||
@@ -465,7 +428,7 @@ export function useGroupWebRTC() {
|
||||
}
|
||||
function updateParticipantStream(userId: string, stream: MediaStream) {
|
||||
const p = participants.value.find(p => p.userId === userId)
|
||||
if (p) p.stream = stream
|
||||
if (p) { p.stream = stream; p.status = 'connected' }
|
||||
}
|
||||
function updateParticipantStatus(userId: string, status: any) {
|
||||
const p = participants.value.find(p => p.userId === userId)
|
||||
|
||||
Reference in New Issue
Block a user