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({ 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(null) const remoteStream = shallowRef(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('') 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 模式才处理 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() } } else if (signal === 'answer') { // WebRTC 模式才处理 answer(RTMP 模式不使用 WebRTC 信令) if (!useRTMP.value && pc) { await pc.setRemoteDescription(content) processPendingCandidates() } } else if (signal === 'candidate') { // 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() } } 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 { // 如果已有缓存,直接使用 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 { 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 { 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 模式下,发起方先假设使用 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 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 { 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 可以直接重封装为 FLV,VP8/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 { 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, } }