Files
nl-um-vue-ts/src/composables/useGroupWebRTC.ts

525 lines
18 KiB
TypeScript
Raw Normal View History

2025-12-06 23:02:49 +08:00
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
}
}