音视频通话功能完成(P2P)

已知问题:
文件无法发送
This commit is contained in:
2025-12-03 15:33:48 +08:00
parent be7bd1279a
commit 9db9b65ea3
15 changed files with 1544 additions and 704 deletions

View File

@@ -1,7 +1,8 @@
import { ref, reactive } from 'vue'
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'
@@ -14,9 +15,13 @@ export interface CallState {
id: string | null
muted: boolean
camOff: boolean
duration: number
startTime: number | null
}
export function useWebRTC(userId: string) {
export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string) => void) {
const toastStore = useToastStore()
const call = reactive<CallState>({
active: false,
minimized: false,
@@ -26,91 +31,134 @@ export function useWebRTC(userId: string) {
id: null,
muted: false,
camOff: false,
duration: 0,
startTime: null,
})
const localVideo = ref<HTMLVideoElement | null>(null)
const remoteVideo = ref<HTMLVideoElement | null>(null)
// 使用 shallowRef 存储 MediaStream避免 Vue 进行深层代理导致的性能问题
const localStream = shallowRef<MediaStream | null>(null)
const remoteStream = shallowRef<MediaStream | null>(null)
let durationTimer: number | null = null
let pc: RTCPeerConnection | null = null
let localStream: MediaStream | null = null
const pendingCandidates: RTCIceCandidate[] = []
let currentReceiverUserId = ''
/**
* 初始化媒体流
*/
async function initMedia(videoEnabled: boolean): Promise<void> {
try {
const constraints = { video: videoEnabled, audio: true }
const stream = await navigator.mediaDevices.getUserMedia(constraints)
localStream = stream
if (videoEnabled && localVideo.value) {
localVideo.value.srcObject = stream
// 停止之前的流
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('无法获取设备权限或设备不支持')
throw new Error('无法获取摄像头或麦克风权限')
}
}
/**
* 获取ICE服务器配置
* 获取并增强 ICE Servers 配置
*/
async function getIceServers() {
const servers: RTCIceServer[] = []
try {
const servers = await systemApi.getIceServers(userId)
if (servers && servers.length > 0) {
return servers.map((s) => ({
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.error('Failed to fetch ICE servers:', error)
console.warn('Failed to fetch ICE servers from API')
}
// Fallback: 使用默认STUN
return [{ urls: 'stun:stun.l.google.com:19302' }]
// 添加公共 STUN 服务器作为兜底
servers.push({ urls: 'stun:stun.l.google.com:19302' })
servers.push({ urls: 'stun:global.stun.twilio.com:3478' })
return servers
}
/**
* 创建PeerConnection
* 创建 PeerConnection
*/
async function createPC(): Promise<void> {
const iceServers = await getIceServers()
iceServers.push({ urls: 'stun:stun.l.google.com:19302' })
pc = new RTCPeerConnection({ iceServers })
// 关闭旧连接
if (pc) pc.close()
if (localStream) {
localStream.getTracks().forEach((track) => {
pc!.addTrack(track, localStream!)
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) => {
if (remoteVideo.value) {
remoteVideo.value.srcObject = e.streams[0]
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('通话连接中断')
}
}
}
let currentReceiverUserId = ''
/**
* 发送信令
* 发送信令 (统一封装)
*/
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: receiverUserId || currentReceiverUserId,
receiver_user_id: targetUserId,
room_id: '',
message_type: 6,
message_type: 6, // Signaling Message
content: JSON.stringify(data || {}),
call_id: call.id,
call_status: status,
@@ -120,9 +168,33 @@ export function useWebRTC(userId: string) {
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
@@ -130,160 +202,160 @@ export function useWebRTC(userId: string) {
call.active = true
call.minimized = false
call.status = 'outgoing'
call.statusText = '等待对方接听...'
call.statusText = '正在呼叫...'
call.duration = 0
remoteStream.value = null // 重置远程流
try {
await initMedia(type === 'video')
await createPC()
sendSignal('invite', undefined, receiverUserId)
} catch (error: any) {
alert(error.message || '无法启动通话')
toastStore.error(error.message || '无法启动通话')
endCall()
}
}
/**
* 接听通话
*/
async function acceptCall(senderUserId?: string) {
if (senderUserId) {
currentReceiverUserId = senderUserId
}
if (senderUserId) currentReceiverUserId = senderUserId
call.status = 'connected'
call.statusText = '连接...'
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)
// 发送 accepted等待发起方创建 Offer
sendSignal('accepted', undefined, currentReceiverUserId)
} 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
stopCallTimer()
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
// 停止本地流
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 extra = typeof message.extra === 'string' ? JSON.parse(message.extra || '{}') : message.extra
const content = message.content ? JSON.parse(message.content) : {}
const extra = message.extra ? (typeof message.extra === 'string' ? JSON.parse(message.extra) : message.extra) : {}
let callType = 'video'
if (extra && extra.type) {
callType = extra.type
}
// 更新通话类型
if (extra.type) call.type = 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.id = message.call_id
call.active = true
call.minimized = false
call.status = 'incoming'
call.statusText = `对方邀请进行${callType === 'video' ? '视频' : '语音'}通话...`
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, message.sender_user_id)
sendSignal('offer', offer)
} else if (signal === 'offer') {
const desc = JSON.parse(message.content)
// 接收 Offer创建 Answer
if (!pc) {
await initMedia(call.type === 'video')
await createPC()
}
await pc!.setRemoteDescription(desc)
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') {
await pc!.setRemoteDescription(JSON.parse(message.content))
// 接收 Answer
await pc!.setRemoteDescription(content)
processPendingCandidates()
} else if (signal === 'candidate') {
await pc!.addIceCandidate(JSON.parse(message.content))
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') {
alert('已在其他设备接听')
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) {
localStream.getAudioTracks()[0].enabled = !call.muted
if (localStream.value) {
localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted)
}
}
/**
* 切换摄像头
*/
function toggleCamera() {
call.camOff = !call.camOff
if (localStream) {
const videoTrack = localStream.getVideoTracks()[0]
if (videoTrack) {
videoTrack.enabled = !call.camOff
}
if (localStream.value) {
localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff)
}
}
return {
call,
localVideo,
remoteVideo,
localStream, // 暴露流
remoteStream, // 暴露流
startCall,
acceptCall,
endCall,
handleSignaling,
toggleMute,
toggleCamera,
formatDuration,
}
}