525 lines
18 KiB
TypeScript
525 lines
18 KiB
TypeScript
import { reactive, shallowRef, ref, computed } from 'vue'
|
||
import * as messageApi from '@/api/modules/message'
|
||
import { wsManager } from '@/api/websocket'
|
||
import { useToastStore } from '@/stores/toast'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import type { ChatMessage } from '@/types/api'
|
||
|
||
// --- 类型定义 ---
|
||
export interface CallParticipant {
|
||
userId: string
|
||
name: string
|
||
avatar: string
|
||
stream?: MediaStream
|
||
isMuted: boolean
|
||
isCamOff: boolean
|
||
status: 'connecting' | 'connected' | 'failed'
|
||
volume?: number
|
||
}
|
||
|
||
export interface GroupCallState {
|
||
incoming: boolean
|
||
joined: boolean
|
||
minimized: boolean
|
||
roomId: string
|
||
groupId: string
|
||
initiatorId: string
|
||
inviterName: string
|
||
type: 'audio' | 'video'
|
||
duration: number
|
||
startTime: number | null
|
||
isSelfMuted: boolean
|
||
isSelfCamOff: boolean
|
||
}
|
||
|
||
interface SignalPayload {
|
||
action: 'invite' | 'join' | 'offer' | 'answer' | 'candidate' | 'leave' | 'sync_state' | 'reject'
|
||
callRoomId: string
|
||
senderId: string
|
||
senderName?: string
|
||
targetId?: string
|
||
data?: any
|
||
state?: { muted: boolean; camOff: boolean }
|
||
participantIds?: string[]
|
||
type?: 'audio' | 'video'
|
||
}
|
||
|
||
// --- 全局单例状态 (关键: 必须在函数外) ---
|
||
const callState = reactive<GroupCallState>({
|
||
incoming: false,
|
||
joined: false,
|
||
minimized: false,
|
||
roomId: '',
|
||
groupId: '',
|
||
initiatorId: '',
|
||
inviterName: '',
|
||
type: 'video',
|
||
duration: 0,
|
||
startTime: null,
|
||
isSelfMuted: false,
|
||
isSelfCamOff: false
|
||
})
|
||
|
||
const localStream = shallowRef<MediaStream | null>(null)
|
||
const participants = ref<CallParticipant[]>([])
|
||
const peerConnections = new Map<string, RTCPeerConnection>()
|
||
|
||
// Perfect Negotiation 状态控制
|
||
const makingOffer = new Map<string, boolean>()
|
||
const ignoreOffer = new Map<string, boolean>()
|
||
const pendingCandidates = new Map<string, RTCIceCandidateInit[]>()
|
||
|
||
let durationTimer: number | null = null
|
||
|
||
const iceServers = {
|
||
iceServers: [
|
||
{ urls: 'stun:stun.l.google.com:19302' },
|
||
]
|
||
}
|
||
|
||
export function useGroupWebRTC() {
|
||
const toastStore = useToastStore()
|
||
const authStore = useAuthStore()
|
||
|
||
// 辅助计算属性
|
||
const isActive = computed(() => callState.joined || callState.incoming)
|
||
|
||
function initListener() {
|
||
// 避免重复注册
|
||
wsManager.offSignal(handleSignalMessage)
|
||
wsManager.onSignal(handleSignalMessage)
|
||
}
|
||
|
||
// --- 状态重置 ---
|
||
function resetState() {
|
||
stopTimer()
|
||
peerConnections.forEach(pc => pc.close())
|
||
peerConnections.clear()
|
||
makingOffer.clear()
|
||
ignoreOffer.clear()
|
||
pendingCandidates.clear()
|
||
|
||
if (localStream.value) {
|
||
localStream.value.getTracks().forEach(t => t.stop())
|
||
localStream.value = null
|
||
}
|
||
participants.value = []
|
||
|
||
callState.incoming = false
|
||
callState.joined = false
|
||
callState.minimized = false
|
||
callState.roomId = ''
|
||
callState.groupId = ''
|
||
callState.duration = 0
|
||
callState.initiatorId = ''
|
||
callState.inviterName = ''
|
||
}
|
||
|
||
async function initLocalMedia(videoEnabled: boolean) {
|
||
try {
|
||
if (localStream.value) {
|
||
localStream.value.getTracks().forEach(t => t.stop())
|
||
}
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
video: videoEnabled ? { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' } : false,
|
||
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
||
})
|
||
localStream.value = stream
|
||
callState.isSelfMuted = false
|
||
callState.isSelfCamOff = false
|
||
return stream
|
||
} catch (error) {
|
||
console.error('获取媒体失败', error)
|
||
toastStore.error('无法获取摄像头或麦克风权限')
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// --- 核心信令处理 ---
|
||
async function handleSignalMessage(msg: ChatMessage) {
|
||
const myId = authStore.user?.id
|
||
if (!myId) return
|
||
|
||
try {
|
||
const content: SignalPayload = JSON.parse(msg.content || '{}')
|
||
if (content.senderId === myId) return
|
||
|
||
// 1. 处理邀请 (Invite)
|
||
if (content.action === 'invite') {
|
||
if (!content.participantIds || !content.callRoomId) return
|
||
if (callState.joined) return // 忙线
|
||
|
||
console.log('🔔 收到群通话邀请:', content)
|
||
|
||
callState.incoming = true
|
||
callState.roomId = content.callRoomId
|
||
callState.groupId = msg.room_id || ''
|
||
callState.type = content.type || 'video'
|
||
callState.initiatorId = content.senderId
|
||
callState.inviterName = content.senderName || '群成员'
|
||
return
|
||
}
|
||
|
||
// 2. Banner 状态更新 (被动感知)
|
||
if (content.action === 'join' && msg.room_id) {
|
||
if (callState.roomId && content.callRoomId !== callState.roomId) return
|
||
if (!callState.joined && !callState.incoming) {
|
||
callState.roomId = content.callRoomId
|
||
callState.groupId = msg.room_id
|
||
}
|
||
}
|
||
|
||
if (content.callRoomId !== callState.roomId) return
|
||
|
||
switch (content.action) {
|
||
case 'join':
|
||
addParticipant(content.senderId, content.senderName || '成员', '', 'connecting')
|
||
// 只要我加入了,我就尝试连接新人
|
||
// 这里的连接逻辑交给 getPeerConnection 中的 onnegotiationneeded 自动处理
|
||
if (callState.joined) {
|
||
// 仅仅初始化 PC 并添加轨道,就会触发 negotiation -> 发送 Offer
|
||
getPeerConnection(content.senderId)
|
||
}
|
||
break
|
||
|
||
case 'offer':
|
||
if (content.targetId === myId && callState.joined) {
|
||
addParticipant(content.senderId, content.senderName || '成员', '', 'connecting')
|
||
await handleOffer(content.senderId, content.data)
|
||
}
|
||
break
|
||
|
||
case 'answer':
|
||
if (content.targetId === myId && callState.joined) {
|
||
await handleAnswer(content.senderId, content.data)
|
||
}
|
||
break
|
||
|
||
case 'candidate':
|
||
if (content.targetId === myId && callState.joined) {
|
||
await handleCandidate(content.senderId, content.data)
|
||
}
|
||
break
|
||
|
||
case 'leave':
|
||
removeParticipant(content.senderId)
|
||
if (participants.value.length === 0 && !callState.joined) {
|
||
callState.roomId = ''
|
||
}
|
||
break
|
||
|
||
case 'reject':
|
||
break
|
||
|
||
case 'sync_state':
|
||
updateParticipantState(content.senderId, content.state)
|
||
break
|
||
}
|
||
} catch (e) {
|
||
console.error('Signal processing error', e)
|
||
}
|
||
}
|
||
|
||
// --- 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) {
|
||
console.error('Negotiation failed:', err)
|
||
} finally {
|
||
makingOffer.set(targetUserId, false)
|
||
}
|
||
}
|
||
|
||
pc.onicecandidate = (event) => {
|
||
if (event.candidate) sendSignal('candidate', { targetId: targetUserId, data: event.candidate })
|
||
}
|
||
|
||
pc.ontrack = (event) => {
|
||
if (event.streams[0]) updateParticipantStream(targetUserId, event.streams[0])
|
||
}
|
||
|
||
pc.onconnectionstatechange = () => {
|
||
if (pc.connectionState === 'connected') updateParticipantStatus(targetUserId, 'connected')
|
||
else if (pc.connectionState === 'failed') updateParticipantStatus(targetUserId, 'failed')
|
||
}
|
||
|
||
peerConnections.set(targetUserId, pc)
|
||
return pc
|
||
}
|
||
|
||
// 处理 Offer (解决 Glare 冲突)
|
||
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)) {
|
||
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 sendSignal('answer', { targetId: senderId, data: pc.localDescription })
|
||
|
||
// 处理堆积的 Candidates
|
||
await flushPendingCandidates(senderId, pc)
|
||
} catch (e) {
|
||
console.error('Handle offer failed:', e)
|
||
}
|
||
}
|
||
|
||
async function handleAnswer(senderId: string, answerSdp: RTCSessionDescriptionInit) {
|
||
const pc = getPeerConnection(senderId)
|
||
const isIgnored = ignoreOffer.get(senderId)
|
||
|
||
if (isIgnored) 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)
|
||
}
|
||
}
|
||
|
||
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) {
|
||
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 => {})
|
||
}
|
||
pendingCandidates.delete(userId)
|
||
}
|
||
|
||
async function sendSignal(action: SignalPayload['action'], payload: Partial<SignalPayload>) {
|
||
const myId = authStore.user?.id
|
||
if (!myId || !callState.groupId) return
|
||
|
||
const fullPayload: SignalPayload = {
|
||
action,
|
||
callRoomId: callState.roomId,
|
||
senderId: myId,
|
||
senderName: authStore.user?.name || '我',
|
||
...payload
|
||
}
|
||
|
||
let receiverId = ''
|
||
if (['offer', 'answer', 'candidate'].includes(action)) receiverId = payload.targetId || ''
|
||
|
||
const message = {
|
||
room_id: callState.groupId,
|
||
receiver_user_id: receiverId,
|
||
message_type: 6,
|
||
content: JSON.stringify(fullPayload),
|
||
call_status: action as any,
|
||
}
|
||
await messageApi.sendMessage(message)
|
||
}
|
||
|
||
// --- 操作方法 ---
|
||
|
||
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 显示
|
||
|
||
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('无法启动通话:请检查摄像头/麦克风权限')
|
||
resetState()
|
||
}
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
}
|
||
|
||
function rejectInvite() {
|
||
sendSignal('reject', {})
|
||
resetState()
|
||
}
|
||
|
||
async function joinCurrentCall() {
|
||
if (!callState.roomId) return
|
||
await acceptInvite()
|
||
}
|
||
|
||
function leaveCall() {
|
||
if (callState.joined) {
|
||
sendSignal('leave', {})
|
||
}
|
||
resetState()
|
||
}
|
||
|
||
// --- 辅助函数 ---
|
||
function addParticipant(userId: string, name: string, avatar: string, status: any) {
|
||
const idx = participants.value.findIndex(p => p.userId === userId)
|
||
if (idx === -1) participants.value.push({ userId, name, avatar, status, isMuted: false, isCamOff: false })
|
||
else participants.value[idx].status = status
|
||
}
|
||
function removeParticipant(userId: string) {
|
||
const idx = participants.value.findIndex(p => p.userId === userId)
|
||
if (idx > -1) participants.value.splice(idx, 1)
|
||
const pc = peerConnections.get(userId)
|
||
if (pc) { pc.close(); peerConnections.delete(userId) }
|
||
}
|
||
function updateParticipantStream(userId: string, stream: MediaStream) {
|
||
const p = participants.value.find(p => p.userId === userId)
|
||
if (p) p.stream = stream
|
||
}
|
||
function updateParticipantStatus(userId: string, status: any) {
|
||
const p = participants.value.find(p => p.userId === userId)
|
||
if (p) p.status = status
|
||
}
|
||
function updateParticipantState(userId: string, state: any) {
|
||
if (!state) return
|
||
const p = participants.value.find(p => p.userId === userId)
|
||
if (p) { p.isMuted = state.muted; p.isCamOff = state.camOff }
|
||
}
|
||
|
||
function toggleSelfMute() {
|
||
callState.isSelfMuted = !callState.isSelfMuted
|
||
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !callState.isSelfMuted)
|
||
sendSignal('sync_state', { state: { muted: callState.isSelfMuted, camOff: callState.isSelfCamOff } })
|
||
}
|
||
|
||
function toggleSelfCamera() {
|
||
callState.isSelfCamOff = !callState.isSelfCamOff
|
||
if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !callState.isSelfCamOff)
|
||
sendSignal('sync_state', { state: { muted: callState.isSelfMuted, camOff: callState.isSelfCamOff } })
|
||
}
|
||
|
||
function startTimer() {
|
||
callState.startTime = Date.now()
|
||
durationTimer = window.setInterval(() => {
|
||
if (callState.startTime) callState.duration = Math.floor((Date.now() - callState.startTime) / 1000)
|
||
}, 1000)
|
||
}
|
||
|
||
function stopTimer() {
|
||
if (durationTimer) clearInterval(durationTimer)
|
||
}
|
||
|
||
function formatDuration(seconds: number) {
|
||
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
|
||
const s = (seconds % 60).toString().padStart(2, '0')
|
||
return `${m}:${s}`
|
||
}
|
||
|
||
return {
|
||
callState,
|
||
localStream,
|
||
participants,
|
||
isActive,
|
||
startGroupCall,
|
||
acceptInvite,
|
||
rejectInvite,
|
||
joinCurrentCall,
|
||
leaveCall,
|
||
toggleSelfMute,
|
||
toggleSelfCamera,
|
||
formatDuration,
|
||
initListener
|
||
}
|
||
}
|