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