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

1144 lines
39 KiB
TypeScript
Raw Normal View History

import { reactive, shallowRef, ref } from 'vue'
2025-12-15 14:13:56 +08:00
import flvjs from 'flv.js'
2025-12-03 13:25:02 +08:00
import * as messageApi from '@/api/modules/message'
2025-12-14 11:08:21 +08:00
import * as systemApi from '@/api/modules/system'
2025-12-15 14:13:56 +08:00
import * as callApi from '@/api/modules/call'
2025-12-03 13:25:02 +08:00
import { wsManager } from '@/api/websocket'
import { useToastStore } from '@/stores/toast'
import { useChatStore } from '@/stores/chat'
2025-12-16 14:10:07 +08:00
import { useAuthStore } from '@/stores/auth'
2025-12-16 09:16:51 +08:00
import { isRTMPMode, isAutoMode, shouldUseRTMP, parsePlatform, type Platform } from '@/config/call'
2025-12-14 11:08:21 +08:00
import type { ChatMessage, Contact, ICEServerConfig } from '@/types/api'
2025-12-03 13:25:02 +08:00
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
remoteMuted: boolean
2025-12-03 13:25:02 +08:00
camOff: boolean
remoteCamOff: boolean
duration: number
startTime: number | null
2025-12-15 14:13:56 +08:00
// 小程序流信息
miniprogramStreams: FlvStreamInfo[]
2025-12-16 14:10:07 +08:00
// 来电者信息(用于来电显示)
callerName?: string
callerAvatar?: string
callerId?: string
2025-12-15 14:13:56 +08:00
}
// FLV 流信息(来自小程序端)
export interface FlvStreamInfo {
userId: string
flvUrl: string
player?: flvjs.Player
2025-12-03 13:25:02 +08:00
}
const RINGTONE_INCOMING_BASE64 = 'data:audio/mp3;base64,//uQxAAAAAAAAAAAAEluZm8AAAAPAAAAHgAABOYADQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NAAAAAAJDTEFtZTMuMTAwBK8AAAAAAAAAABQJAAHIAAAAHgAABObK82LdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uQxAAAAAAABAAAAAAAAAAASbXAzLm9yZwAAAP8AAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uQxAEAMzI2hAAAAANIAAAAQAAAEAQGBAIAQBAEAQCBwAAAAe3/8w9///3v//+57//3///8w9///3v//+57//3///8AAKAAX/4EFwR//uQxBEAM8I2fAAAAANIAAAAQAAAEAAAAAAB///+BACAAAAAAH/9R//qD//6g////+oAAAD/4IHgAAAAAA//6gAAAAAT//uQxBIAOQI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxBsAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxCsAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxDQAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxE4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxE4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxF4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxGwAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxHgAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxIUAAANIAAAAAQAIAAAB//////////////////////////////////////////////////////////////////////////////////uQxIkAAANIAAAAAQAIAAAB////////////////////////////////////////////////////////////////////////////////'
export function useWebRTC(
userId: string,
onIncomingCall?: (senderUserId: string) => void,
getRoomId?: (receiverUserId?: string) => string
) {
const toastStore = useToastStore()
const chatStore = useChatStore()
2025-12-16 14:10:07 +08:00
const authStore = useAuthStore()
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,
remoteMuted: false,
2025-12-03 13:25:02 +08:00
camOff: false,
remoteCamOff: false,
duration: 0,
startTime: null,
2025-12-15 14:13:56 +08:00
miniprogramStreams: [],
2025-12-16 14:10:07 +08:00
// 来电者信息
callerName: undefined,
callerAvatar: undefined,
callerId: undefined,
2025-12-03 13:25:02 +08:00
})
const isCaller = ref(false)
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
const pendingCandidates: RTCIceCandidate[] = []
let currentReceiverUserId = ''
let currentRoomId = ''
2025-12-16 09:16:51 +08:00
// Auto 模式:跟踪对方平台
let remotePlatform: Platform = 'unknown'
const useRTMP = ref(false) // 当前通话是否使用 RTMP 模式
2025-12-03 13:25:02 +08:00
2025-12-15 14:13:56 +08:00
// RTMP 模式相关
let wsPushSocket: WebSocket | null = null
let mediaRecorder: MediaRecorder | null = null
const wsPushUrl = ref<string>('')
const audioIncoming = new Audio(RINGTONE_INCOMING_BASE64)
audioIncoming.loop = true
2025-12-06 23:02:49 +08:00
// --- 信令处理 (修复版) ---
async function handleSignaling(message: ChatMessage) {
try {
const content = message.content ? JSON.parse(message.content) : {}
2025-12-06 23:02:49 +08:00
// 【关键修复】如果是群聊信令,直接忽略!
// 通过判断是否存在 callRoomId 或 participantIds 来识别
if (content.callRoomId || content.participantIds) {
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 忽略群聊信令')
2025-12-06 23:02:49 +08:00
return
}
const signal = message.call_status as any
const extra = message.extra ? (typeof message.extra === 'string' ? JSON.parse(message.extra) : message.extra) : {}
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 📨 收到信令:', signal, {
from: message.sender_user_id,
room_id: message.room_id,
call_id: message.call_id,
content: content,
extra: extra
})
2025-12-06 23:02:49 +08:00
if (extra.type) call.type = extra.type
if (signal === 'sync_state') {
if (content.action === 'cam-toggle') call.remoteCamOff = content.value
else if (content.action === 'mic-toggle') call.remoteMuted = content.value
return
}
if (signal === 'invite') {
2025-12-16 09:16:51 +08:00
// Auto 模式:检测对方平台,决定使用 WebRTC 还是 RTMP
remotePlatform = parsePlatform(extra.platform)
useRTMP.value = shouldUseRTMP(remotePlatform)
console.log('[WebRTC] 📞 收到来电邀请:', {
from: message.sender_user_id,
room_id: message.room_id,
call_id: message.call_id,
type: extra.type,
platform: extra.platform,
remotePlatform,
2025-12-16 14:10:07 +08:00
useRTMP: useRTMP.value,
senderName: extra.senderName,
senderAvatar: extra.senderAvatar
2025-12-16 09:16:51 +08:00
})
if (call.active) {
console.log('[WebRTC] ⚠️ 当前有通话,忽略新来电')
return
}
2025-12-06 23:02:49 +08:00
isCaller.value = false
currentReceiverUserId = message.sender_user_id
if (message.room_id) currentRoomId = message.room_id
call.id = message.call_id
call.active = true
call.minimized = false
call.status = 'incoming'
call.statusText = `邀请你通话`
2025-12-16 14:10:07 +08:00
// 设置来电者信息
call.callerId = message.sender_user_id
// 优先从信令 extra 获取来电者信息(小程序端会发送)
if (extra.senderName) {
call.callerName = extra.senderName
call.callerAvatar = extra.senderAvatar
} else {
// 否则从本地联系人列表查找
const contact = chatStore.contacts.find(c =>
c.contact_user_id === message.sender_user_id ||
c.user_id === message.sender_user_id
)
if (contact?.user) {
call.callerName = contact.remark_name || contact.user.name
call.callerAvatar = contact.user.avatar
}
}
console.log('[WebRTC] 👤 来电者信息:', { name: call.callerName, avatar: call.callerAvatar })
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] ✅ 设置来电状态, roomId:', currentRoomId, 'mode:', useRTMP.value ? 'RTMP' : 'WebRTC')
2025-12-06 23:02:49 +08:00
playRingtone('incoming')
if (onIncomingCall) onIncomingCall(message.sender_user_id)
} else if (signal === 'accepted') {
2025-12-16 09:16:51 +08:00
// Auto 模式:从 accepted 信令中检测对方平台,决定最终使用的模式
const previousMode = useRTMP.value
if (extra.platform) {
remotePlatform = parsePlatform(extra.platform)
useRTMP.value = shouldUseRTMP(remotePlatform)
console.log('[WebRTC] 📱 对方平台:', extra.platform, '→', remotePlatform,
'| 模式:', useRTMP.value ? 'RTMP' : 'WebRTC',
previousMode !== useRTMP.value ? '(已切换)' : '')
} else {
// 如果没有 platform 信息,默认认为对方是 h5,使用 WebRTC
remotePlatform = 'h5'
useRTMP.value = false
console.log('[WebRTC] ⚠️ 对方未提供平台信息,默认为 h5,使用 WebRTC')
}
console.log('[WebRTC] ✅ 对方已接听, 最终模式:', useRTMP.value ? 'RTMP' : 'WebRTC')
2025-12-06 23:02:49 +08:00
stopRingtone()
call.status = 'connected'
call.statusText = '通话中'
startCallTimer()
2025-12-15 14:13:56 +08:00
// 加入房间获取小程序用户的 FLV 流地址(RTMP 模式下也会启动 WebSocket 推流)
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 📡 准备加入房间, roomId:', currentRoomId, 'mode:', useRTMP.value ? 'RTMP' : 'WebRTC')
2025-12-15 14:13:56 +08:00
await joinCallRoomAndGetFlvUrls()
2025-12-16 09:16:51 +08:00
// WebRTC 模式:创建 Offer(只有 useRTMP 为 false 时才使用 WebRTC)
if (!useRTMP.value) {
console.log('[WebRTC] WebRTC 模式:创建 Offer')
2025-12-16 10:21:29 +08:00
if (!pc) await createPC()
const offer = await pc!.createOffer()
await pc!.setLocalDescription(offer)
sendSignal('offer', offer)
2025-12-16 09:16:51 +08:00
} else {
console.log('[WebRTC] RTMP 模式:等待 FLV 流')
2025-12-15 14:13:56 +08:00
}
2025-12-06 23:02:49 +08:00
} else if (signal === 'offer') {
2025-12-16 09:16:51 +08:00
// WebRTC 模式才处理 offer(RTMP 模式不使用 WebRTC 信令)
if (!useRTMP.value) {
stopRingtone()
if (!pc) {
await initMedia(call.type === 'video')
await createPC()
}
await pc!.setRemoteDescription(content)
processPendingCandidates()
const answer = await pc!.createAnswer()
await pc!.setLocalDescription(answer)
sendSignal('answer', answer, message.sender_user_id)
call.status = 'connected'
call.statusText = '通话中'
startCallTimer()
2025-12-06 23:02:49 +08:00
}
} else if (signal === 'answer') {
2025-12-16 09:16:51 +08:00
// WebRTC 模式才处理 answer(RTMP 模式不使用 WebRTC 信令)
if (!useRTMP.value && pc) {
2025-12-06 23:02:49 +08:00
await pc.setRemoteDescription(content)
processPendingCandidates()
}
} else if (signal === 'candidate') {
2025-12-16 09:16:51 +08:00
// WebRTC 模式才处理 candidate(RTMP 模式不使用 WebRTC 信令)
if (!useRTMP.value) {
if (pc && pc.remoteDescription) await pc.addIceCandidate(content)
else pendingCandidates.push(content)
}
} else if (signal === 'participant_joined') {
// 新参与者加入房间(来自小程序端)
console.log('[WebRTC] 🆕 收到 participant_joined 信令:', {
user_id: content.user_id,
platform: content.platform,
flv_url: content.flv_url,
pull_url: content.pull_url
})
// 如果有 FLV 地址,添加或更新小程序流列表
if (content.flv_url && content.user_id) {
const existingIndex = call.miniprogramStreams.findIndex(s => s.userId === content.user_id)
if (existingIndex >= 0) {
// 更新现有流的地址(重要:当用户重新加入时使用新的流地址)
const oldFlvUrl = call.miniprogramStreams[existingIndex].flvUrl
call.miniprogramStreams[existingIndex].flvUrl = content.flv_url
console.log('[WebRTC] 🔄 更新小程序流地址:', content.user_id)
console.log('[WebRTC] 旧地址:', oldFlvUrl)
console.log('[WebRTC] 新地址:', content.flv_url)
} else {
// 添加新的小程序流
call.miniprogramStreams.push({
userId: content.user_id,
flvUrl: content.flv_url
})
console.log('[WebRTC] ✅ 添加小程序流:', content.user_id, content.flv_url)
}
console.log('[WebRTC] 📺 当前小程序流列表:', call.miniprogramStreams)
} else {
console.log('[WebRTC] ⚠️ participant_joined 没有 FLV 地址')
}
// 如果当前没有小程序流,尝试重新获取房间信息
if (call.miniprogramStreams.length === 0 && currentRoomId) {
console.log('[WebRTC] 📡 没有小程序流,尝试刷新房间信息...')
refreshRoomStreams()
2025-12-15 14:13:56 +08:00
}
2025-12-06 23:02:49 +08:00
} else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) {
if (isCaller.value) {
if (call.status === 'outgoing') sendSummaryMessage('rejected')
else if (call.status === 'connected') sendSummaryMessage('connected')
}
closeCall()
if (signal === 'answered_elsewhere') toastStore.info('已在其他设备接听')
}
} catch (error) {
console.error('Error handling signaling:', error)
}
}
2025-12-06 23:02:49 +08:00
// ... (其余辅助函数保持不变,为节省篇幅只展示关键变动,但你需要完整代码,下面补充完整辅助函数) ...
function playRingtone(type: 'incoming' | 'dialing') {
stopRingtone()
const playPromise = audioIncoming.play()
if (playPromise !== undefined) playPromise.catch(e => {})
}
function stopRingtone() {
audioIncoming.pause()
audioIncoming.currentTime = 0
}
function getSafeRoomId(targetUserId?: string): string | null {
if (currentRoomId) return currentRoomId
if (targetUserId && getRoomId) {
const rid = getRoomId(targetUserId)
if (rid) return rid
2025-12-03 13:25:02 +08:00
}
if (targetUserId && userId) {
return [userId, targetUserId].sort().join('_')
}
return null
}
2025-12-03 13:25:02 +08:00
async function sendSummaryMessage(reason: 'connected' | 'cancelled' | 'rejected' | 'busy') {
2025-12-14 11:08:21 +08:00
const roomId = getSafeRoomId(currentReceiverUserId)
if (!roomId) return
2025-12-14 12:19:47 +08:00
// 通话类型说明
const callTypeText = call.type === 'video' ? '[视频通话]' : '[语音通话]'
2025-12-14 11:08:21 +08:00
// 根据原因生成系统消息内容
let content = ''
switch (reason) {
case 'connected':
2025-12-14 12:19:47 +08:00
content = `${callTypeText} 通话时长 ${formatDuration(call.duration)}`
2025-12-14 11:08:21 +08:00
break
case 'cancelled':
2025-12-14 12:19:47 +08:00
content = `${callTypeText} 已取消`
2025-12-14 11:08:21 +08:00
break
case 'rejected':
2025-12-14 12:19:47 +08:00
content = `${callTypeText} 对方未接听`
2025-12-14 11:08:21 +08:00
break
case 'busy':
2025-12-14 12:19:47 +08:00
content = `${callTypeText} 对方忙`
2025-12-14 11:08:21 +08:00
break
}
// 构造通话结果的 extra 数据
const extraData = {
call_type: call.type,
call_reason: reason,
call_duration: call.duration,
call_id: call.id
}
try {
// 发送系统消息 (message_type = 4)
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: currentReceiverUserId,
room_id: roomId,
message_type: 4, // 系统消息
content: content,
extra: JSON.stringify(extraData)
}
await messageApi.sendMessage(payload)
// 同时添加到本地消息列表
const systemMessage: ChatMessage = {
id: Date.now(),
room_id: roomId,
sender_user_id: userId,
message_type: 4,
content: content,
extra: extraData,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
chatStore.addMessage(roomId, systemMessage)
} catch (error) {
console.error('发送通话结束消息失败:', error)
}
}
// ICE 服务器缓存
let cachedIceServers: RTCIceServer[] | null = null
/**
* 获取 ICE 服务器配置(优先从后端 API 获取,失败时使用备用配置)
*/
async function getIceServers(): Promise<RTCIceServer[]> {
// 如果已有缓存,直接使用
if (cachedIceServers) {
return cachedIceServers
}
try {
const servers = await systemApi.getIceServers(userId)
if (servers && servers.length > 0) {
// 转换为 RTCIceServer 格式
cachedIceServers = servers.map((s: ICEServerConfig) => ({
urls: s.urls,
username: s.username,
credential: s.credential
}))
return cachedIceServers
}
} catch (error) {
console.warn('获取 ICE 服务器配置失败,使用备用配置:', error)
}
// 备用配置:公共 STUN 服务器
cachedIceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
return cachedIceServers
}
// --- WebRTC ---
async function initMedia(videoEnabled: boolean): Promise<void> {
try {
if (localStream.value) {
localStream.value.getTracks().forEach(t => t.stop())
localStream.value = null
}
const constraints: MediaStreamConstraints = {
2025-12-06 23:02:49 +08:00
video: videoEnabled ? { width: { ideal: 640 }, height: { ideal: 480 } } : false,
audio: { echoCancellation: true, noiseSuppression: true },
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
localStream.value = stream
} catch (error: any) {
2025-12-06 23:02:49 +08:00
throw new Error('无法获取设备权限')
}
}
async function createPC(): Promise<void> {
2025-12-14 11:08:21 +08:00
const iceServers = await getIceServers()
2025-12-06 23:02:49 +08:00
if (pc) { pc.close(); pc = null }
2025-12-14 11:08:21 +08:00
pc = new RTCPeerConnection({ iceServers })
pc.oniceconnectionstatechange = () => {
2025-12-06 23:02:49 +08:00
if (pc?.iceConnectionState === 'failed') {
call.statusText = '连接失败'
endCall()
} else if (pc?.iceConnectionState === 'connected') {
call.statusText = '通话中'
}
}
if (localStream.value) {
localStream.value.getTracks().forEach((track) => pc!.addTrack(track, localStream.value!))
}
pc.ontrack = (e) => {
if (e.streams && e.streams[0]) {
remoteStream.value = e.streams[0]
}
}
pc.onicecandidate = (e) => {
if (e.candidate && call.id) sendSignal('candidate', e.candidate)
}
}
2025-12-03 13:25:02 +08:00
function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) {
2025-12-16 09:16:51 +08:00
if (!call.id) {
console.warn('[WebRTC] ⚠️ sendSignal: call.id 为空,跳过')
return
}
const targetUserId = receiverUserId || currentReceiverUserId
const roomId = getSafeRoomId(targetUserId)
2025-12-16 09:16:51 +08:00
if (!roomId) {
console.warn('[WebRTC] ⚠️ sendSignal: roomId 为空,跳过')
return
}
console.log('[WebRTC] 📤 发送信令:', status, { to: targetUserId, roomId, callId: call.id })
2025-12-16 14:10:07 +08:00
// 获取当前用户信息,发送给对方(让对方显示来电者信息)
const currentUser = authStore.user
2025-12-03 13:25:02 +08:00
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: targetUserId,
room_id: roomId,
message_type: 6,
2025-12-03 13:25:02 +08:00
content: JSON.stringify(data || {}),
call_id: call.id,
call_status: status,
2025-12-16 14:10:07 +08:00
// 包含平台信息和发送者信息,让对方显示来电者昵称和头像
extra: JSON.stringify({
type: call.type,
platform: 'web',
senderName: currentUser?.name,
senderAvatar: currentUser?.avatar
}),
2025-12-03 13:25:02 +08:00
}
2025-12-16 09:16:51 +08:00
messageApi.sendMessage(payload).catch(err => {
console.error('[WebRTC] ❌ 发送信令失败:', err)
})
}
2025-12-03 13:25:02 +08:00
function sendSyncState(type: 'cam-toggle' | 'mic-toggle', value: boolean) {
if (call.status !== 'connected') return
const roomId = getSafeRoomId(currentReceiverUserId)
if (!roomId) return
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: currentReceiverUserId,
room_id: roomId,
message_type: 6,
content: JSON.stringify({ action: type, value }),
call_id: call.id,
call_status: 'sync_state' as any,
extra: JSON.stringify({ type: call.type })
}
2025-12-03 13:25:02 +08:00
messageApi.sendMessage(payload).catch(console.error)
}
async function startCall(type: 'audio' | 'video', receiverUserId: string, roomId?: string, contact?: Contact) {
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 📞 发起通话:', { type, receiverUserId, roomId, contactRoomId: contact?.room_id })
if (roomId) currentRoomId = roomId
else if (contact?.room_id) currentRoomId = contact.room_id
else if (getRoomId) {
const found = getRoomId(receiverUserId)
if (found) currentRoomId = found
}
if (!currentRoomId) {
2025-12-16 09:16:51 +08:00
console.error('[WebRTC] ❌ 缺少房间信息')
toastStore.error('无法建立通话连接:缺少房间信息')
return false
}
2025-12-16 09:16:51 +08:00
// Auto 模式:发起通话时,默认使用 WebRTC,收到 accepted 后根据对方平台调整
// 如果对方是小程序,会在 accepted 处理时切换到 RTMP
// 固定模式:使用配置的模式
if (isAutoMode()) {
// Auto 模式下,发起方先假设使用 WebRTC(Web-to-Web 最优)
// 收到 accepted 后,如果对方是小程序才切换到 RTMP
useRTMP.value = false
console.log('[WebRTC] ✅ 房间ID:', currentRoomId, 'Auto 模式:发起通话,默认 WebRTC,等待对方平台信息')
} else {
useRTMP.value = isRTMPMode()
console.log('[WebRTC] ✅ 房间ID:', currentRoomId, '固定模式:', useRTMP.value ? 'RTMP' : 'WebRTC')
}
isCaller.value = true
2025-12-03 13:25:02 +08:00
currentReceiverUserId = receiverUserId
2025-12-16 09:16:51 +08:00
remotePlatform = 'unknown' // 重置远程平台
2025-12-03 13:25:02 +08:00
call.type = type
call.id = Date.now().toString()
call.active = true
call.minimized = false
call.status = 'outgoing'
call.statusText = '正在呼叫...'
call.duration = 0
call.camOff = false
call.remoteCamOff = false
call.remoteMuted = false
remoteStream.value = null
2025-12-03 13:25:02 +08:00
try {
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 🎥 初始化媒体设备...')
2025-12-03 13:25:02 +08:00
await initMedia(type === 'video')
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] ✅ 媒体设备初始化成功')
2025-12-15 14:13:56 +08:00
2025-12-16 09:16:51 +08:00
// 根据当前模式选择不同的推流方式
if (useRTMP.value) {
console.log('[WebRTC] 📡 使用 RTMP 模式(auto 模式下可能在 accepted 后切换)')
2025-12-15 14:13:56 +08:00
// RTMP 模式:加入房间后会自动开始 WebSocket 推流
} else {
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 📡 使用 WebRTC 模式')
2025-12-16 10:21:29 +08:00
await createPC()
2025-12-15 14:13:56 +08:00
}
playRingtone('dialing')
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 📤 发送 invite 信令给:', receiverUserId)
2025-12-03 13:25:02 +08:00
sendSignal('invite', undefined, receiverUserId)
return true
2025-12-03 13:25:02 +08:00
} catch (error: any) {
2025-12-16 09:16:51 +08:00
console.error('[WebRTC] ❌ 发起通话失败:', error)
toastStore.error(error.message || '无法启动通话')
closeCall()
return false
2025-12-03 13:25:02 +08:00
}
}
async function acceptCall(senderUserId?: string) {
2025-12-16 09:16:51 +08:00
// 接听时 useRTMP 已经在 invite 处理时设置好了
console.log('[WebRTC] 📞 接听来电:', { senderUserId, currentRoomId, mode: useRTMP.value ? 'RTMP' : 'WebRTC' })
isCaller.value = false
stopRingtone()
if (senderUserId) currentReceiverUserId = senderUserId
2025-12-03 13:25:02 +08:00
call.status = 'connected'
call.statusText = '正在连接...'
call.camOff = false
call.remoteCamOff = false
2025-12-03 13:25:02 +08:00
try {
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 🎥 初始化媒体设备...')
2025-12-03 13:25:02 +08:00
await initMedia(call.type === 'video')
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] ✅ 媒体设备初始化成功')
2025-12-15 14:13:56 +08:00
2025-12-16 09:16:51 +08:00
// 根据模式选择不同的推流方式(useRTMP 在收到 invite 时已确定)
if (useRTMP.value) {
console.log('[WebRTC] 📡 接听:使用 RTMP 模式,准备加入房间')
2025-12-15 14:13:56 +08:00
// RTMP 模式:加入房间后会自动开始 WebSocket 推流
await joinCallRoomAndGetFlvUrls()
} else {
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 📡 接听:使用 WebRTC 模式')
2025-12-16 10:21:29 +08:00
await createPC()
2025-12-15 14:13:56 +08:00
}
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 📤 发送 accepted 信令给:', currentReceiverUserId)
sendSignal('accepted', undefined, currentReceiverUserId)
2025-12-03 13:25:02 +08:00
} catch (error) {
2025-12-16 09:16:51 +08:00
console.error('[WebRTC] ❌ 接听失败:', error)
2025-12-03 13:25:02 +08:00
endCall()
}
}
function endCall() {
stopRingtone()
2025-12-14 12:19:47 +08:00
// 只有发起方发送通话结束系统消息,避免重复
2025-12-14 11:08:21 +08:00
if (isCaller.value) {
if (call.status === 'outgoing') {
sendSummaryMessage('cancelled') // 呼出状态主动取消
} else if (call.status === 'connected') {
sendSummaryMessage('connected') // 通话中主动挂断
}
}
2025-12-14 12:19:47 +08:00
// 被叫方不发送消息,由发起方统一发送
2025-12-03 13:25:02 +08:00
sendSignal('hangup')
2025-12-15 14:13:56 +08:00
// 离开通话房间
leaveCallRoom()
2025-12-03 13:25:02 +08:00
closeCall()
}
function closeCall() {
stopRingtone()
currentRoomId = ''
2025-12-03 13:25:02 +08:00
call.active = false
call.status = 'idle'
call.statusText = ''
call.id = null
2025-12-16 14:10:07 +08:00
// 清除来电者信息
call.callerName = undefined
call.callerAvatar = undefined
call.callerId = undefined
stopCallTimer()
2025-12-15 14:13:56 +08:00
// 停止 WebSocket 推流 (RTMP 模式)
stopWebSocketPush()
if (pc) {
pc.close()
pc = null
}
if (localStream.value) {
2025-12-06 23:02:49 +08:00
localStream.value.getTracks().forEach(t => t.stop())
localStream.value = null
}
remoteStream.value = null
2025-12-15 14:13:56 +08:00
// 清理 FLV 播放器
destroyFlvPlayers()
call.miniprogramStreams = []
}
/**
* 加入通话房间并获取小程序用户的 FLV 流地址
*/
async function joinCallRoomAndGetFlvUrls(): Promise<void> {
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 🚪 准备加入房间:', { roomId: currentRoomId, userId })
if (!currentRoomId) {
console.error('[WebRTC] ❌ joinCallRoomAndGetFlvUrls: currentRoomId 为空,跳过')
return
}
2025-12-15 14:13:56 +08:00
try {
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 📤 发送加入房间请求...')
2025-12-15 14:13:56 +08:00
const response = await callApi.joinCallRoom({
room_id: currentRoomId,
user_id: userId,
2025-12-16 09:16:51 +08:00
platform: 'web'
2025-12-15 14:13:56 +08:00
})
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] ✅ 加入房间响应:', JSON.stringify(response, null, 2))
2025-12-15 14:13:56 +08:00
// 检查是否有小程序用户的 FLV 流
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 📺 检查 FLV 拉流地址:', response.flv_pull_urls)
2025-12-15 14:13:56 +08:00
if (response.flv_pull_urls && response.flv_pull_urls.length > 0) {
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] ✅ 检测到小程序用户流:', response.flv_pull_urls.length, '个')
2025-12-15 14:13:56 +08:00
call.miniprogramStreams = response.flv_pull_urls.map(p => ({
userId: p.user_id,
flvUrl: p.flv_url || ''
})).filter(s => s.flvUrl)
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 📺 已添加小程序流:', call.miniprogramStreams)
} else {
console.log('[WebRTC] ⚠️ 没有检测到小程序用户流')
2025-12-15 14:13:56 +08:00
}
2025-12-16 09:16:51 +08:00
// 检查参与者列表
console.log('[WebRTC] 👥 房间参与者:', response.participants)
2025-12-15 14:13:56 +08:00
// RTMP 模式:获取 WebSocket 推流地址并开始推流
2025-12-16 09:16:51 +08:00
if (useRTMP.value && response.ws_push_url) {
2025-12-15 14:13:56 +08:00
wsPushUrl.value = response.ws_push_url
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] RTMP 模式,WebSocket 推流地址:', response.ws_push_url)
// 确保 localStream 存在再推流,增加重试机制
const tryStartPush = (retryCount = 0) => {
if (localStream.value) {
console.log('[WebRTC] ✅ localStream 就绪,开始 WebSocket 推流')
startWebSocketPush(response.ws_push_url!)
} else if (retryCount < 10) {
console.warn(`[WebRTC] ⚠️ localStream 未就绪,500ms 后重试 (${retryCount + 1}/10)`)
setTimeout(() => tryStartPush(retryCount + 1), 500)
} else {
console.error('[WebRTC] ❌ localStream 始终未就绪,放弃推流')
}
2025-12-15 14:13:56 +08:00
}
2025-12-16 09:16:51 +08:00
tryStartPush()
2025-12-15 14:13:56 +08:00
}
2025-12-16 09:16:51 +08:00
// 关键修复:通知房间内其他参与者"我已加入"
// 发送 participant_joined 信令,让小程序端知道可以拉取我的流
sendParticipantJoinedSignal(response)
2025-12-15 14:13:56 +08:00
} catch (error) {
console.error('[WebRTC] 加入房间失败:', error)
}
}
2025-12-16 09:16:51 +08:00
/**
* 发送 participant_joined 信令通知其他参与者
*/
function sendParticipantJoinedSignal(response: callApi.JoinCallRoomResponse) {
console.log('[WebRTC] 📤 准备发送 participant_joined 信令:', { callId: call.id, roomId: currentRoomId, to: currentReceiverUserId })
if (!call.id || !currentRoomId) {
console.warn('[WebRTC] ⚠️ sendParticipantJoinedSignal: call.id 或 roomId 为空,跳过')
return
}
// 构建参与者信息,包含自己的 FLV 地址供小程序拉流
const participantInfo = {
user_id: userId,
platform: 'web',
// Web 端的 FLV 地址(供小程序拉流)
pull_url: response.ws_push_url || '', // WebSocket 推流地址(仅供参考)
flv_url: response.self_flv_url || '', // HTTP-FLV 地址(供小程序 live-player 播放)
}
console.log('[WebRTC] 📤 发送 participant_joined 信令:', participantInfo, 'to:', currentReceiverUserId)
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: currentReceiverUserId,
room_id: currentRoomId,
message_type: 6,
content: JSON.stringify(participantInfo),
call_id: call.id,
call_status: 'participant_joined' as any,
extra: JSON.stringify({ type: call.type, platform: 'web' }),
}
messageApi.sendMessage(payload).then(() => {
console.log('[WebRTC] ✅ participant_joined 信令发送成功')
}).catch(err => {
console.error('[WebRTC] ❌ participant_joined 信令发送失败:', err)
})
}
/**
* 刷新房间流信息(当收到 participant_joined 但没有流地址时)
*/
async function refreshRoomStreams() {
if (!currentRoomId) return
try {
const response = await callApi.joinCallRoom({
room_id: currentRoomId,
user_id: userId,
platform: 'web'
})
console.log('[WebRTC] 刷新房间流信息:', response)
// 更新小程序流列表
if (response.flv_pull_urls && response.flv_pull_urls.length > 0) {
for (const p of response.flv_pull_urls) {
const exists = call.miniprogramStreams.some(s => s.userId === p.user_id)
if (!exists && p.flv_url) {
call.miniprogramStreams.push({
userId: p.user_id,
flvUrl: p.flv_url
})
console.log('[WebRTC] 刷新后添加小程序流:', p.user_id, p.flv_url)
}
}
}
} catch (error) {
console.error('[WebRTC] 刷新房间流信息失败:', error)
}
}
2025-12-15 14:13:56 +08:00
/**
* 开始 WebSocket 推流 (RTMP 模式)
* 使用 MediaRecorder 编码本地流,通过 WebSocket 发送到服务器
*/
function startWebSocketPush(wsUrl: string): void {
if (!localStream.value) {
console.error('[WebRTC] 无法推流:本地流不存在')
return
}
// 关闭已有连接
stopWebSocketPush()
console.log('[WebRTC] 开始 WebSocket 推流:', wsUrl)
// 创建 WebSocket 连接
wsPushSocket = new WebSocket(wsUrl)
wsPushSocket.onopen = () => {
console.log('[WebRTC] WebSocket 连接已建立')
// 连接成功后开始录制
startMediaRecorder()
}
wsPushSocket.onclose = (event) => {
console.log('[WebRTC] WebSocket 连接已关闭:', event.code, event.reason)
}
wsPushSocket.onerror = (error) => {
console.error('[WebRTC] WebSocket 错误:', error)
toastStore.error('推流连接失败')
}
wsPushSocket.onmessage = (event) => {
// 处理服务器消息(如有需要)
console.log('[WebRTC] WebSocket 收到消息:', event.data)
}
}
/**
* 开始 MediaRecorder 录制
2025-12-16 09:16:51 +08:00
* 优先使用 H.264 编码,因为 H.264 可以直接封装到 FLV,无需转码
* 如果浏览器不支持 H.264,则降级到 VP8/VP9(需要服务器端 FFmpeg 转码)
2025-12-15 14:13:56 +08:00
*/
function startMediaRecorder(): void {
if (!localStream.value || !wsPushSocket) {
return
}
// 检查浏览器支持的 MIME 类型
2025-12-16 09:16:51 +08:00
// 优先级:H.264 > VP8 > VP9 > 默认
// H.264 可以直接重封装为 FLV,VP8/VP9 需要 FFmpeg 转码
2025-12-15 14:13:56 +08:00
const mimeTypes = [
2025-12-16 09:16:51 +08:00
'video/webm;codecs=h264,opus', // 优先:H.264 可直接封装 FLV
'video/webm;codecs=avc1,opus', // 备选:H.264 另一种写法
'video/webm;codecs=vp8,opus', // 降级:需要 FFmpeg 转码
'video/webm;codecs=vp9,opus', // 降级:需要 FFmpeg 转码
'video/webm', // 最后兜底
2025-12-15 14:13:56 +08:00
]
let selectedMimeType = ''
2025-12-16 09:16:51 +08:00
let selectedCodec = 'unknown'
2025-12-15 14:13:56 +08:00
for (const mimeType of mimeTypes) {
if (MediaRecorder.isTypeSupported(mimeType)) {
selectedMimeType = mimeType
2025-12-16 09:16:51 +08:00
// 解析出视频编解码器
if (mimeType.includes('h264') || mimeType.includes('avc1')) {
selectedCodec = 'h264'
} else if (mimeType.includes('vp9')) {
selectedCodec = 'vp9'
} else if (mimeType.includes('vp8')) {
selectedCodec = 'vp8'
}
2025-12-15 14:13:56 +08:00
break
}
}
if (!selectedMimeType) {
console.error('[WebRTC] 浏览器不支持 WebM 录制')
toastStore.error('您的浏览器不支持视频推流')
return
}
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 使用编码格式:', selectedMimeType, '视频编解码器:', selectedCodec)
// 如果不是 H.264,提示用户可能需要转码
if (selectedCodec !== 'h264') {
console.warn('[WebRTC] ⚠️ 浏览器不支持 H.264 编码,使用', selectedCodec, '(需要服务器转码)')
}
2025-12-15 14:13:56 +08:00
try {
mediaRecorder = new MediaRecorder(localStream.value, {
mimeType: selectedMimeType,
videoBitsPerSecond: 500000, // 500kbps
audioBitsPerSecond: 64000, // 64kbps
})
2025-12-16 09:16:51 +08:00
// 首次发送编解码器信息给服务器
let codecInfoSent = false
2025-12-15 14:13:56 +08:00
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0 && wsPushSocket?.readyState === WebSocket.OPEN) {
2025-12-16 09:16:51 +08:00
// 首次发送时,先发送编解码器信息(JSON 格式)
if (!codecInfoSent) {
const codecInfo = JSON.stringify({
type: 'codec_info',
video_codec: selectedCodec,
audio_codec: 'opus',
mime_type: selectedMimeType
})
wsPushSocket.send(codecInfo)
codecInfoSent = true
console.log('[WebRTC] 已发送编解码器信息:', codecInfo)
}
2025-12-15 14:13:56 +08:00
wsPushSocket.send(event.data)
}
}
mediaRecorder.onerror = (error) => {
console.error('[WebRTC] MediaRecorder 错误:', error)
}
mediaRecorder.onstart = () => {
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] MediaRecorder 开始录制, 编解码器:', selectedCodec)
2025-12-15 14:13:56 +08:00
}
mediaRecorder.onstop = () => {
console.log('[WebRTC] MediaRecorder 停止录制')
}
// 每 100ms 发送一次数据
mediaRecorder.start(100)
} catch (error) {
console.error('[WebRTC] 创建 MediaRecorder 失败:', error)
toastStore.error('初始化推流失败')
}
}
/**
* 停止 WebSocket 推流
*/
function stopWebSocketPush(): void {
// 停止 MediaRecorder
if (mediaRecorder) {
try {
if (mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
} catch (e) {
// 忽略错误
}
mediaRecorder = null
}
// 关闭 WebSocket
if (wsPushSocket) {
try {
wsPushSocket.close()
} catch (e) {
// 忽略错误
}
wsPushSocket = null
}
wsPushUrl.value = ''
console.log('[WebRTC] WebSocket 推流已停止')
}
/**
* 离开通话房间
*/
async function leaveCallRoom(): Promise<void> {
if (!currentRoomId) return
try {
await callApi.leaveCallRoom({
room_id: currentRoomId,
user_id: userId
})
} catch (error) {
console.error('[WebRTC] 离开房间失败:', error)
}
}
/**
* 初始化 FLV 播放器
* @param videoElement 视频元素
* @param streamIndex 流索引
*/
function initFlvPlayer(videoElement: HTMLVideoElement, streamIndex: number = 0): flvjs.Player | null {
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 🎬 初始化 FLV 播放器, streamIndex:', streamIndex, '当前流列表:', call.miniprogramStreams)
2025-12-15 14:13:56 +08:00
if (!flvjs.isSupported()) {
2025-12-16 09:16:51 +08:00
console.error('[WebRTC] ❌ 浏览器不支持 flv.js')
2025-12-15 14:13:56 +08:00
toastStore.error('您的浏览器不支持 FLV 播放')
return null
}
const stream = call.miniprogramStreams[streamIndex]
if (!stream || !stream.flvUrl) {
2025-12-16 09:16:51 +08:00
console.error('[WebRTC] ❌ 没有可用的 FLV 流, stream:', stream)
2025-12-15 14:13:56 +08:00
return null
}
// 如果已有播放器,先销毁
if (stream.player) {
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 🔄 销毁已有的 FLV 播放器')
2025-12-15 14:13:56 +08:00
stream.player.destroy()
}
2025-12-16 09:16:51 +08:00
console.log('[WebRTC] 🎬 创建 FLV 播放器:', stream.flvUrl)
2025-12-15 14:13:56 +08:00
const player = flvjs.createPlayer({
type: 'flv',
url: stream.flvUrl,
isLive: true,
hasAudio: true,
hasVideo: true,
}, {
enableWorker: false,
enableStashBuffer: false,
stashInitialSize: 128,
lazyLoad: false,
})
player.attachMediaElement(videoElement)
player.load()
player.play()
// 监听错误
2025-12-16 09:16:51 +08:00
player.on(flvjs.Events.ERROR, (errorType, errorDetail, errorInfo) => {
console.error('[WebRTC] FLV 播放错误:', errorType, errorDetail, errorInfo)
2025-12-15 14:13:56 +08:00
if (errorType === flvjs.ErrorTypes.NETWORK_ERROR) {
toastStore.error('网络错误,无法播放小程序视频流')
2025-12-16 09:16:51 +08:00
} else if (errorType === flvjs.ErrorTypes.MEDIA_ERROR) {
// 处理媒体错误,包括编解码器不支持
if (errorDetail === 'CodecUnsupported') {
console.error('[WebRTC] 音视频编解码器不支持,小程序端需要配置 audio-codec="aac"')
toastStore.error('视频格式不兼容,请让小程序端更新后重试')
} else {
toastStore.error('视频播放失败: ' + errorDetail)
}
2025-12-15 14:13:56 +08:00
}
})
// 保存播放器引用
stream.player = player
return player
}
/**
* 销毁所有 FLV 播放器
*/
function destroyFlvPlayers(): void {
for (const stream of call.miniprogramStreams) {
if (stream.player) {
try {
stream.player.pause()
stream.player.unload()
stream.player.detachMediaElement()
stream.player.destroy()
} catch (e) {
console.error('[WebRTC] 销毁 FLV 播放器失败:', e)
}
stream.player = undefined
}
}
}
/**
* 检查是否有小程序流需要播放
*/
function hasMiniprogramStreams(): boolean {
return call.miniprogramStreams.length > 0
2025-12-03 13:25:02 +08:00
}
async function processPendingCandidates() {
while (pendingCandidates.length > 0) {
const c = pendingCandidates.shift()
2025-12-06 23:02:49 +08:00
if (c && pc) await pc.addIceCandidate(c).catch(e => {})
2025-12-03 13:25:02 +08:00
}
}
function startCallTimer() {
stopCallTimer()
call.startTime = Date.now()
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')
const h = Math.floor(seconds / 3600)
return h > 0 ? `${h.toString().padStart(2, '0')}:${m}:${s}` : `${m}:${s}`
}
2025-12-03 13:25:02 +08:00
function toggleMute() {
call.muted = !call.muted
sendSyncState('mic-toggle', call.muted)
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
sendSyncState('cam-toggle', call.camOff)
if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff)
2025-12-03 13:25:02 +08:00
}
return {
call,
localStream,
remoteStream,
startCall,
acceptCall,
endCall,
handleSignaling,
toggleMute,
toggleCamera,
2025-12-15 14:13:56 +08:00
formatDuration,
// FLV 播放相关(用于小程序流)
initFlvPlayer,
destroyFlvPlayers,
hasMiniprogramStreams,
2025-12-03 13:25:02 +08:00
}
}