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

1152 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { reactive, shallowRef, ref } from 'vue'
import flvjs from 'flv.js'
import * as messageApi from '@/api/modules/message'
import * as systemApi from '@/api/modules/system'
import * as callApi from '@/api/modules/call'
import { wsManager } from '@/api/websocket'
import { useToastStore } from '@/stores/toast'
import { useChatStore } from '@/stores/chat'
import { useAuthStore } from '@/stores/auth'
import { isRTMPMode, isAutoMode, shouldUseRTMP, parsePlatform, type Platform } from '@/config/call'
import type { ChatMessage, Contact, ICEServerConfig } 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
remoteMuted: boolean
camOff: boolean
remoteCamOff: boolean
duration: number
startTime: number | null
// 小程序流信息
miniprogramStreams: FlvStreamInfo[]
// 来电者信息(用于来电显示)
callerName?: string
callerAvatar?: string
callerId?: string
}
// FLV 流信息(来自小程序端)
export interface FlvStreamInfo {
userId: string
flvUrl: string
player?: flvjs.Player
}
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()
const authStore = useAuthStore()
const call = reactive<CallState>({
active: false,
minimized: false,
type: 'video',
status: 'idle',
statusText: '',
id: null,
muted: false,
remoteMuted: false,
camOff: false,
remoteCamOff: false,
duration: 0,
startTime: null,
miniprogramStreams: [],
// 来电者信息
callerName: undefined,
callerAvatar: undefined,
callerId: undefined,
})
const isCaller = ref(false)
const localStream = shallowRef<MediaStream | null>(null)
const remoteStream = shallowRef<MediaStream | null>(null)
let durationTimer: number | null = null
let pc: RTCPeerConnection | null = null
const pendingCandidates: RTCIceCandidate[] = []
let currentReceiverUserId = ''
let currentRoomId = ''
// Auto 模式:跟踪对方平台
let remotePlatform: Platform = 'unknown'
const useRTMP = ref(false) // 当前通话是否使用 RTMP 模式
// 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
// --- 信令处理 (修复版) ---
async function handleSignaling(message: ChatMessage) {
try {
const content = message.content ? JSON.parse(message.content) : {}
// 【关键修复】如果是群聊信令,直接忽略!
// 通过判断是否存在 callRoomId 或 participantIds 来识别
if (content.callRoomId || content.participantIds) {
console.log('[WebRTC] 忽略群聊信令')
return
}
const signal = message.call_status as any
const extra = message.extra ? (typeof message.extra === 'string' ? JSON.parse(message.extra) : message.extra) : {}
console.log('[WebRTC] 📨 收到信令:', signal, {
from: message.sender_user_id,
room_id: message.room_id,
call_id: message.call_id,
content: content,
extra: extra
})
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') {
// 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,
useRTMP: useRTMP.value,
senderName: extra.senderName,
senderAvatar: extra.senderAvatar
})
if (call.active) {
console.log('[WebRTC] ⚠️ 当前有通话,忽略新来电')
return
}
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 = `邀请你通话`
// 设置来电者信息
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 })
console.log('[WebRTC] ✅ 设置来电状态, roomId:', currentRoomId, 'mode:', useRTMP.value ? 'RTMP' : 'WebRTC')
playRingtone('incoming')
if (onIncomingCall) onIncomingCall(message.sender_user_id)
} else if (signal === 'accepted') {
// 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')
stopRingtone()
call.status = 'connected'
call.statusText = '通话中'
startCallTimer()
// 加入房间获取小程序用户的 FLV 流地址RTMP 模式下也会启动 WebSocket 推流)
console.log('[WebRTC] 📡 准备加入房间, roomId:', currentRoomId, 'mode:', useRTMP.value ? 'RTMP' : 'WebRTC')
await joinCallRoomAndGetFlvUrls()
// WebRTC 模式:创建 Offer只有 useRTMP 为 false 时才使用 WebRTC
if (!useRTMP.value) {
console.log('[WebRTC] WebRTC 模式:创建 Offer')
if (!pc) await createPC()
const offer = await pc!.createOffer()
await pc!.setLocalDescription(offer)
sendSignal('offer', offer)
} else {
console.log('[WebRTC] RTMP 模式:等待 FLV 流')
}
} else if (signal === 'offer') {
// WebRTC 模式才处理 offerRTMP 模式不使用 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()
}
} else if (signal === 'answer') {
// WebRTC 模式才处理 answerRTMP 模式不使用 WebRTC 信令)
if (!useRTMP.value && pc) {
await pc.setRemoteDescription(content)
processPendingCandidates()
}
} else if (signal === 'candidate') {
// WebRTC 模式才处理 candidateRTMP 模式不使用 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()
}
} 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)
}
}
// ... (其余辅助函数保持不变,为节省篇幅只展示关键变动,但你需要完整代码,下面补充完整辅助函数) ...
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
}
if (targetUserId && userId) {
return [userId, targetUserId].sort().join('_')
}
return null
}
async function sendSummaryMessage(reason: 'connected' | 'cancelled' | 'rejected' | 'busy') {
const roomId = getSafeRoomId(currentReceiverUserId)
if (!roomId) return
// 通话类型说明
const callTypeText = call.type === 'video' ? '[视频通话]' : '[语音通话]'
// 根据原因生成系统消息内容
let content = ''
switch (reason) {
case 'connected':
content = `${callTypeText} 通话时长 ${formatDuration(call.duration)}`
break
case 'cancelled':
content = `${callTypeText} 已取消`
break
case 'rejected':
content = `${callTypeText} 对方未接听`
break
case 'busy':
content = `${callTypeText} 对方忙`
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 = {
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) {
throw new Error('无法获取设备权限')
}
}
async function createPC(): Promise<void> {
const iceServers = await getIceServers()
if (pc) { pc.close(); pc = null }
pc = new RTCPeerConnection({ iceServers })
pc.oniceconnectionstatechange = () => {
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)
}
}
function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) {
if (!call.id) {
console.warn('[WebRTC] ⚠️ sendSignal: call.id 为空,跳过')
return
}
const targetUserId = receiverUserId || currentReceiverUserId
const roomId = getSafeRoomId(targetUserId)
if (!roomId) {
console.warn('[WebRTC] ⚠️ sendSignal: roomId 为空,跳过')
return
}
console.log('[WebRTC] 📤 发送信令:', status, { to: targetUserId, roomId, callId: call.id })
// 获取当前用户信息,发送给对方(让对方显示来电者信息)
const currentUser = authStore.user
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: targetUserId,
room_id: roomId,
message_type: 6,
content: JSON.stringify(data || {}),
call_id: call.id,
call_status: status,
// 包含平台信息和发送者信息,让对方显示来电者昵称和头像
extra: JSON.stringify({
type: call.type,
platform: 'web',
senderName: currentUser?.name,
senderAvatar: currentUser?.avatar
}),
}
messageApi.sendMessage(payload).catch(err => {
console.error('[WebRTC] ❌ 发送信令失败:', err)
})
}
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 })
}
messageApi.sendMessage(payload).catch(console.error)
}
async function startCall(type: 'audio' | 'video', receiverUserId: string, roomId?: string, contact?: Contact) {
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) {
console.error('[WebRTC] ❌ 缺少房间信息')
toastStore.error('无法建立通话连接:缺少房间信息')
return false
}
// Auto 模式:发起通话时,默认使用 WebRTC收到 accepted 后根据对方平台调整
// 如果对方是小程序,会在 accepted 处理时切换到 RTMP
// 固定模式:使用配置的模式
if (isAutoMode()) {
// Auto 模式下,发起方先假设使用 WebRTCWeb-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
currentReceiverUserId = receiverUserId
remotePlatform = 'unknown' // 重置远程平台
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
try {
console.log('[WebRTC] 🎥 初始化媒体设备...')
await initMedia(type === 'video')
console.log('[WebRTC] ✅ 媒体设备初始化成功')
// 根据当前模式选择不同的推流方式
if (useRTMP.value) {
console.log('[WebRTC] 📡 使用 RTMP 模式auto 模式下可能在 accepted 后切换)')
// RTMP 模式:加入房间后会自动开始 WebSocket 推流
} else {
console.log('[WebRTC] 📡 使用 WebRTC 模式')
await createPC()
}
playRingtone('dialing')
console.log('[WebRTC] 📤 发送 invite 信令给:', receiverUserId)
sendSignal('invite', undefined, receiverUserId)
return true
} catch (error: any) {
console.error('[WebRTC] ❌ 发起通话失败:', error)
toastStore.error(error.message || '无法启动通话')
closeCall()
return false
}
}
async function acceptCall(senderUserId?: string) {
// 接听时 useRTMP 已经在 invite 处理时设置好了
console.log('[WebRTC] 📞 接听来电:', { senderUserId, currentRoomId, mode: useRTMP.value ? 'RTMP' : 'WebRTC' })
isCaller.value = false
stopRingtone()
if (senderUserId) currentReceiverUserId = senderUserId
call.status = 'connected'
call.statusText = '正在连接...'
call.camOff = false
call.remoteCamOff = false
try {
console.log('[WebRTC] 🎥 初始化媒体设备...')
await initMedia(call.type === 'video')
console.log('[WebRTC] ✅ 媒体设备初始化成功')
// 根据模式选择不同的推流方式useRTMP 在收到 invite 时已确定)
if (useRTMP.value) {
console.log('[WebRTC] 📡 接听:使用 RTMP 模式,准备加入房间')
// RTMP 模式:加入房间后会自动开始 WebSocket 推流
await joinCallRoomAndGetFlvUrls()
} else {
console.log('[WebRTC] 📡 接听:使用 WebRTC 模式')
await createPC()
}
console.log('[WebRTC] 📤 发送 accepted 信令给:', currentReceiverUserId)
sendSignal('accepted', undefined, currentReceiverUserId)
} catch (error) {
console.error('[WebRTC] ❌ 接听失败:', error)
endCall()
}
}
function endCall() {
stopRingtone()
// 只有发起方发送通话结束系统消息,避免重复
if (isCaller.value) {
if (call.status === 'outgoing') {
sendSummaryMessage('cancelled') // 呼出状态主动取消
} else if (call.status === 'connected') {
sendSummaryMessage('connected') // 通话中主动挂断
}
}
// 被叫方不发送消息,由发起方统一发送
sendSignal('hangup')
// 离开通话房间
leaveCallRoom()
closeCall()
}
function closeCall() {
stopRingtone()
currentRoomId = ''
call.active = false
call.status = 'idle'
call.statusText = ''
call.id = null
// 清除来电者信息
call.callerName = undefined
call.callerAvatar = undefined
call.callerId = undefined
stopCallTimer()
// 停止 WebSocket 推流 (RTMP 模式)
stopWebSocketPush()
if (pc) {
pc.close()
pc = null
}
if (localStream.value) {
localStream.value.getTracks().forEach(t => t.stop())
localStream.value = null
}
remoteStream.value = null
// 清理 FLV 播放器
destroyFlvPlayers()
call.miniprogramStreams = []
}
/**
* 加入通话房间并获取小程序用户的 FLV 流地址
*/
async function joinCallRoomAndGetFlvUrls(): Promise<void> {
console.log('[WebRTC] 🚪 准备加入房间:', { roomId: currentRoomId, userId })
if (!currentRoomId) {
console.error('[WebRTC] ❌ joinCallRoomAndGetFlvUrls: currentRoomId 为空,跳过')
return
}
try {
console.log('[WebRTC] 📤 发送加入房间请求...')
const response = await callApi.joinCallRoom({
room_id: currentRoomId,
user_id: userId,
platform: 'web'
})
console.log('[WebRTC] ✅ 加入房间响应:', JSON.stringify(response, null, 2))
// 检查是否有小程序用户的 FLV 流
console.log('[WebRTC] 📺 检查 FLV 拉流地址:', response.flv_pull_urls)
if (response.flv_pull_urls && response.flv_pull_urls.length > 0) {
console.log('[WebRTC] ✅ 检测到小程序用户流:', response.flv_pull_urls.length, '个')
call.miniprogramStreams = response.flv_pull_urls.map(p => ({
userId: p.user_id,
flvUrl: p.flv_url || ''
})).filter(s => s.flvUrl)
console.log('[WebRTC] 📺 已添加小程序流:', call.miniprogramStreams)
} else {
console.log('[WebRTC] ⚠️ 没有检测到小程序用户流')
}
// 检查参与者列表
console.log('[WebRTC] 👥 房间参与者:', response.participants)
// RTMP 模式:获取 WebSocket 推流地址并开始推流
if (useRTMP.value && response.ws_push_url) {
wsPushUrl.value = response.ws_push_url
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 始终未就绪,放弃推流')
}
}
tryStartPush()
}
// 关键修复:通知房间内其他参与者"我已加入"
// 发送 participant_joined 信令,让小程序端知道可以拉取我的流
sendParticipantJoinedSignal(response)
} catch (error) {
console.error('[WebRTC] 加入房间失败:', error)
}
}
/**
* 发送 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)
}
}
/**
* 开始 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 录制
* 优先使用 H.264 编码,因为 H.264 可以直接封装到 FLV无需转码
* 如果浏览器不支持 H.264,则降级到 VP8/VP9需要服务器端 FFmpeg 转码)
*/
function startMediaRecorder(): void {
if (!localStream.value || !wsPushSocket) {
return
}
// 检查浏览器支持的 MIME 类型
// 优先级H.264 > VP8 > VP9 > 默认
// H.264 可以直接重封装为 FLVVP8/VP9 需要 FFmpeg 转码
const mimeTypes = [
'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', // 最后兜底
]
let selectedMimeType = ''
let selectedCodec = 'unknown'
for (const mimeType of mimeTypes) {
if (MediaRecorder.isTypeSupported(mimeType)) {
selectedMimeType = mimeType
// 解析出视频编解码器
if (mimeType.includes('h264') || mimeType.includes('avc1')) {
selectedCodec = 'h264'
} else if (mimeType.includes('vp9')) {
selectedCodec = 'vp9'
} else if (mimeType.includes('vp8')) {
selectedCodec = 'vp8'
}
break
}
}
if (!selectedMimeType) {
console.error('[WebRTC] 浏览器不支持 WebM 录制')
toastStore.error('您的浏览器不支持视频推流')
return
}
console.log('[WebRTC] 使用编码格式:', selectedMimeType, '视频编解码器:', selectedCodec)
// 如果不是 H.264,提示用户可能需要转码
if (selectedCodec !== 'h264') {
console.warn('[WebRTC] ⚠️ 浏览器不支持 H.264 编码,使用', selectedCodec, '(需要服务器转码)')
}
try {
mediaRecorder = new MediaRecorder(localStream.value, {
mimeType: selectedMimeType,
videoBitsPerSecond: 500000, // 500kbps
audioBitsPerSecond: 64000, // 64kbps
})
// 首次发送编解码器信息给服务器
let codecInfoSent = false
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0 && wsPushSocket?.readyState === WebSocket.OPEN) {
// 首次发送时先发送编解码器信息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)
}
wsPushSocket.send(event.data)
}
}
mediaRecorder.onerror = (error) => {
console.error('[WebRTC] MediaRecorder 错误:', error)
}
mediaRecorder.onstart = () => {
console.log('[WebRTC] MediaRecorder 开始录制, 编解码器:', selectedCodec)
}
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 {
console.log('[WebRTC] 🎬 初始化 FLV 播放器, streamIndex:', streamIndex, '当前流列表:', call.miniprogramStreams)
if (!flvjs.isSupported()) {
console.error('[WebRTC] ❌ 浏览器不支持 flv.js')
toastStore.error('您的浏览器不支持 FLV 播放')
return null
}
const stream = call.miniprogramStreams[streamIndex]
if (!stream || !stream.flvUrl) {
console.error('[WebRTC] ❌ 没有可用的 FLV 流, stream:', stream)
return null
}
// 如果已有播放器,先完整销毁(必须按顺序调用 pause, unload, detachMediaElement, destroy
if (stream.player) {
console.log('[WebRTC] 🔄 销毁已有的 FLV 播放器')
try {
stream.player.pause()
stream.player.unload()
stream.player.detachMediaElement()
stream.player.destroy()
} catch (e) {
console.warn('[WebRTC] 销毁 FLV 播放器时出错:', e)
}
stream.player = undefined
}
console.log('[WebRTC] 🎬 创建 FLV 播放器:', stream.flvUrl)
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()
// 监听错误
player.on(flvjs.Events.ERROR, (errorType, errorDetail, errorInfo) => {
console.error('[WebRTC] FLV 播放错误:', errorType, errorDetail, errorInfo)
if (errorType === flvjs.ErrorTypes.NETWORK_ERROR) {
toastStore.error('网络错误,无法播放小程序视频流')
} else if (errorType === flvjs.ErrorTypes.MEDIA_ERROR) {
// 处理媒体错误,包括编解码器不支持
if (errorDetail === 'CodecUnsupported') {
console.error('[WebRTC] 音视频编解码器不支持,小程序端需要配置 audio-codec="aac"')
toastStore.error('视频格式不兼容,请让小程序端更新后重试')
} else {
toastStore.error('视频播放失败: ' + errorDetail)
}
}
})
// 保存播放器引用
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
}
async function processPendingCandidates() {
while (pendingCandidates.length > 0) {
const c = pendingCandidates.shift()
if (c && pc) await pc.addIceCandidate(c).catch(e => {})
}
}
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}`
}
function toggleMute() {
call.muted = !call.muted
sendSyncState('mic-toggle', call.muted)
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted)
}
function toggleCamera() {
call.camOff = !call.camOff
sendSyncState('cam-toggle', 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,
// FLV 播放相关(用于小程序流)
initFlvPlayer,
destroyFlvPlayers,
hasMiniprogramStreams,
}
}