import { reactive, shallowRef } from 'vue' import * as systemApi from '@/api/modules/system' import * as messageApi from '@/api/modules/message' import { wsManager } from '@/api/websocket' import { useToastStore } from '@/stores/toast' import type { ChatMessage } from '@/types/api' import type { CallStatus } from '@/types/message' export interface CallState { active: boolean minimized: boolean type: 'audio' | 'video' status: 'idle' | 'outgoing' | 'incoming' | 'connected' statusText: string id: string | null muted: boolean camOff: boolean duration: number startTime: number | null } export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string) => void) { const toastStore = useToastStore() const call = reactive({ active: false, minimized: false, type: 'video', status: 'idle', statusText: '', id: null, muted: false, camOff: false, duration: 0, startTime: null, }) // 使用 shallowRef 存储 MediaStream,避免 Vue 进行深层代理导致的性能问题 const localStream = shallowRef(null) const remoteStream = shallowRef(null) let durationTimer: number | null = null let pc: RTCPeerConnection | null = null const pendingCandidates: RTCIceCandidate[] = [] let currentReceiverUserId = '' /** * 初始化媒体流 */ async function initMedia(videoEnabled: boolean): Promise { try { // 停止之前的流 if (localStream.value) { localStream.value.getTracks().forEach(t => t.stop()) } const constraints: MediaStreamConstraints = { video: videoEnabled ? { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user', // 优先使用前置摄像头 } : false, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, } const stream = await navigator.mediaDevices.getUserMedia(constraints) localStream.value = stream // 赋值给响应式对象,视图会自动更新 console.log('[WebRTC] Local media stream obtained') } catch (error) { console.error('Failed to get media:', error) throw new Error('无法获取摄像头或麦克风权限') } } /** * 获取并增强 ICE Servers 配置 */ async function getIceServers() { const servers: RTCIceServer[] = [] try { const apiServers = await systemApi.getIceServers(userId) if (apiServers && apiServers.length > 0) { servers.push(...apiServers.map((s: any) => ({ urls: s.urls, username: s.username, credential: s.credential, }))) } } catch (error) { console.warn('Failed to fetch ICE servers from API') } // 添加公共 STUN 服务器作为兜底 servers.push({ urls: 'stun:stun.l.google.com:19302' }) servers.push({ urls: 'stun:global.stun.twilio.com:3478' }) return servers } /** * 创建 PeerConnection */ async function createPC(): Promise { const iceServers = await getIceServers() // 关闭旧连接 if (pc) pc.close() pc = new RTCPeerConnection({ iceServers, iceCandidatePoolSize: 10 }) // 添加本地轨道 if (localStream.value) { localStream.value.getTracks().forEach((track) => { pc!.addTrack(track, localStream.value!) }) } else { console.warn('[WebRTC] No local stream to add to PC') } // 监听远程轨道 pc.ontrack = (e) => { console.log('[WebRTC] Received remote track', e.streams) if (e.streams && e.streams[0]) { remoteStream.value = e.streams[0] // 赋值给响应式对象 } } // 监听 ICE 候选 pc.onicecandidate = (e) => { if (e.candidate && call.id) { sendSignal('candidate', e.candidate) } } // 监听连接状态 pc.onconnectionstatechange = () => { console.log('[WebRTC] Connection state:', pc?.connectionState) if (pc?.connectionState === 'disconnected' || pc?.connectionState === 'failed') { toastStore.error('通话连接中断') } } } /** * 发送信令 (统一封装) */ function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) { if (!call.id) return const targetUserId = receiverUserId || currentReceiverUserId if (!targetUserId) return const payload = { sender_client_id: wsManager.getClientId() || '', receiver_user_id: targetUserId, room_id: '', message_type: 6, // Signaling Message content: JSON.stringify(data || {}), call_id: call.id, call_status: status, extra: JSON.stringify({ type: call.type }), } messageApi.sendMessage(payload).catch(console.error) } // --- 计时器逻辑 --- function startCallTimer() { stopCallTimer() call.startTime = Date.now() call.duration = 0 durationTimer = window.setInterval(() => { if (call.startTime) call.duration = Math.floor((Date.now() - call.startTime) / 1000) }, 1000) } function stopCallTimer() { if (durationTimer) { clearInterval(durationTimer) durationTimer = null } call.startTime = null call.duration = 0 } function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60).toString().padStart(2, '0') const s = (seconds % 60).toString().padStart(2, '0') return `${m}:${s}` } // --- 通话控制 --- async function startCall(type: 'audio' | 'video', receiverUserId: string) { currentReceiverUserId = receiverUserId call.type = type call.id = Date.now().toString() call.active = true call.minimized = false call.status = 'outgoing' call.statusText = '正在呼叫...' call.duration = 0 remoteStream.value = null // 重置远程流 try { await initMedia(type === 'video') await createPC() sendSignal('invite', undefined, receiverUserId) } catch (error: any) { toastStore.error(error.message || '无法启动通话') endCall() } } async function acceptCall(senderUserId?: string) { if (senderUserId) currentReceiverUserId = senderUserId call.status = 'connected' call.statusText = '正在连接...' try { await initMedia(call.type === 'video') await createPC() // 发送 accepted,等待发起方创建 Offer sendSignal('accepted', undefined, currentReceiverUserId) } catch (error) { endCall() } } function endCall() { sendSignal('hangup') closeCall() } function closeCall() { call.active = false call.status = 'idle' call.statusText = '' call.id = null stopCallTimer() if (pc) { pc.close() pc = null } // 停止本地流 if (localStream.value) { localStream.value.getTracks().forEach(t => t.stop()) localStream.value = null } remoteStream.value = null } // --- 信令处理 --- async function handleSignaling(message: ChatMessage) { const signal = message.call_status as CallStatus const content = message.content ? JSON.parse(message.content) : {} const extra = message.extra ? (typeof message.extra === 'string' ? JSON.parse(message.extra) : message.extra) : {} // 更新通话类型 if (extra.type) call.type = extra.type if (signal === 'invite') { currentReceiverUserId = message.sender_user_id call.id = message.call_id call.active = true call.minimized = false call.status = 'incoming' call.statusText = `邀请你进行${call.type === 'video' ? '视频' : '语音'}通话` if (onIncomingCall) onIncomingCall(message.sender_user_id) } else if (signal === 'accepted') { // 对方已接受,作为发起方,创建 Offer call.status = 'connected' call.statusText = '通话中' startCallTimer() const offer = await pc!.createOffer() await pc!.setLocalDescription(offer) sendSignal('offer', offer) } else if (signal === 'offer') { // 接收 Offer,创建 Answer if (!pc) { await initMedia(call.type === 'video') await createPC() } await pc!.setRemoteDescription(content) // 处理缓冲的 Candidates processPendingCandidates() const answer = await pc!.createAnswer() await pc!.setLocalDescription(answer) sendSignal('answer', answer, message.sender_user_id) call.status = 'connected' call.statusText = '通话中' startCallTimer() } else if (signal === 'answer') { // 接收 Answer await pc!.setRemoteDescription(content) processPendingCandidates() } else if (signal === 'candidate') { if (pc && pc.remoteDescription) { await pc.addIceCandidate(content) } else { pendingCandidates.push(content) } } else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) { closeCall() if (signal === 'answered_elsewhere') toastStore.info('已在其他设备接听') } } async function processPendingCandidates() { while (pendingCandidates.length > 0) { const candidate = pendingCandidates.shift() if (candidate && pc) { await pc.addIceCandidate(candidate) } } } // --- 媒体控制 --- function toggleMute() { call.muted = !call.muted if (localStream.value) { localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted) } } function toggleCamera() { call.camOff = !call.camOff if (localStream.value) { localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff) } } return { call, localStream, // 暴露流 remoteStream, // 暴露流 startCall, acceptCall, endCall, handleSignaling, toggleMute, toggleCamera, formatDuration, } }