import { ref, reactive } from 'vue' import * as systemApi from '@/api/modules/system' import * as messageApi from '@/api/modules/message' import { wsManager } from '@/api/websocket' 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 } export function useWebRTC(userId: string) { const call = reactive({ active: false, minimized: false, type: 'video', status: 'idle', statusText: '', id: null, muted: false, camOff: false, }) const localVideo = ref(null) const remoteVideo = ref(null) let pc: RTCPeerConnection | null = null let localStream: MediaStream | null = null /** * 初始化媒体流 */ async function initMedia(videoEnabled: boolean): Promise { try { const constraints = { video: videoEnabled, audio: true } const stream = await navigator.mediaDevices.getUserMedia(constraints) localStream = stream if (videoEnabled && localVideo.value) { localVideo.value.srcObject = stream } } catch (error) { console.error('Failed to get media:', error) throw new Error('无法获取设备权限或设备不支持') } } /** * 获取ICE服务器配置 */ async function getIceServers() { try { const servers = await systemApi.getIceServers(userId) if (servers && servers.length > 0) { return servers.map((s) => ({ urls: s.urls, username: s.username, credential: s.credential, })) } } catch (error) { console.error('Failed to fetch ICE servers:', error) } // Fallback: 使用默认STUN return [{ urls: 'stun:stun.l.google.com:19302' }] } /** * 创建PeerConnection */ async function createPC(): Promise { const iceServers = await getIceServers() iceServers.push({ urls: 'stun:stun.l.google.com:19302' }) pc = new RTCPeerConnection({ iceServers }) if (localStream) { localStream.getTracks().forEach((track) => { pc!.addTrack(track, localStream!) }) } pc.ontrack = (e) => { if (remoteVideo.value) { remoteVideo.value.srcObject = e.streams[0] } } pc.onicecandidate = (e) => { if (e.candidate && call.id) { sendSignal('candidate', e.candidate) } } } let currentReceiverUserId = '' /** * 发送信令 */ function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) { if (!call.id) return const payload = { sender_client_id: wsManager.getClientId() || '', receiver_user_id: receiverUserId || currentReceiverUserId, room_id: '', message_type: 6, content: JSON.stringify(data || {}), call_id: call.id, call_status: status, extra: JSON.stringify({ type: call.type }), } messageApi.sendMessage(payload).catch(console.error) } /** * 开始通话 */ 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 = '等待对方接听...' try { await initMedia(type === 'video') await createPC() sendSignal('invite', undefined, receiverUserId) } catch (error: any) { alert(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() const offer = await pc!.createOffer() await pc!.setLocalDescription(offer) sendSignal('accepted', undefined, senderUserId) sendSignal('offer', offer, senderUserId) } catch (error) { console.error('Failed to accept call:', error) endCall() } } /** * 结束通话 */ function endCall() { sendSignal('hangup') closeCall() } /** * 关闭通话 */ function closeCall() { call.active = false call.status = 'idle' call.statusText = '' call.id = null if (pc) { pc.close() pc = null } if (localStream) { localStream.getTracks().forEach((track) => track.stop()) localStream = null } if (localVideo.value) { localVideo.value.srcObject = null } if (remoteVideo.value) { remoteVideo.value.srcObject = null } } /** * 处理信令消息 */ async function handleSignaling(message: ChatMessage) { const signal = message.call_status as CallStatus const extra = typeof message.extra === 'string' ? JSON.parse(message.extra || '{}') : message.extra let callType = 'video' if (extra && extra.type) { callType = extra.type } if (signal === 'invite') { currentReceiverUserId = message.sender_user_id call.id = message.call_id || Date.now().toString() call.type = callType as 'audio' | 'video' call.active = true call.minimized = false call.status = 'incoming' call.statusText = `对方邀请您进行${callType === 'video' ? '视频' : '语音'}通话...` } else if (signal === 'accepted') { call.status = 'connected' call.statusText = '通话中' const offer = await pc!.createOffer() await pc!.setLocalDescription(offer) sendSignal('offer', offer, message.sender_user_id) } else if (signal === 'offer') { const desc = JSON.parse(message.content) if (!pc) { await initMedia(call.type === 'video') await createPC() } await pc!.setRemoteDescription(desc) const answer = await pc!.createAnswer() await pc!.setLocalDescription(answer) sendSignal('answer', answer, message.sender_user_id) call.status = 'connected' call.statusText = '通话中' } else if (signal === 'answer') { await pc!.setRemoteDescription(JSON.parse(message.content)) } else if (signal === 'candidate') { await pc!.addIceCandidate(JSON.parse(message.content)) } else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) { closeCall() if (signal === 'answered_elsewhere') { alert('已在其他设备接听') } } } /** * 切换静音 */ function toggleMute() { call.muted = !call.muted if (localStream) { localStream.getAudioTracks()[0].enabled = !call.muted } } /** * 切换摄像头 */ function toggleCamera() { call.camOff = !call.camOff if (localStream) { const videoTrack = localStream.getVideoTracks()[0] if (videoTrack) { videoTrack.enabled = !call.camOff } } } return { call, localVideo, remoteVideo, startCall, acceptCall, endCall, handleSignaling, toggleMute, toggleCamera, } }