Files
nl-um-vue-ts/src/composables/useGroupWebRTC.ts
2025-12-06 23:02:49 +08:00

525 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}