2025-12-03 15:58:57 +08:00
|
|
|
|
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'
|
2025-12-03 15:33:48 +08:00
|
|
|
|
import { useToastStore } from '@/stores/toast'
|
2025-12-03 15:58:57 +08:00
|
|
|
|
import { useChatStore } from '@/stores/chat'
|
2025-12-15 14:13:56 +08:00
|
|
|
|
import { isRTMPMode } 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
|
2025-12-04 12:46:15 +08:00
|
|
|
|
remoteMuted: boolean
|
2025-12-03 13:25:02 +08:00
|
|
|
|
camOff: boolean
|
2025-12-03 18:02:26 +08:00
|
|
|
|
remoteCamOff: boolean
|
2025-12-03 15:33:48 +08:00
|
|
|
|
duration: number
|
|
|
|
|
|
startTime: number | null
|
2025-12-15 14:13:56 +08:00
|
|
|
|
// 小程序流信息
|
|
|
|
|
|
miniprogramStreams: FlvStreamInfo[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// FLV 流信息(来自小程序端)
|
|
|
|
|
|
export interface FlvStreamInfo {
|
|
|
|
|
|
userId: string
|
|
|
|
|
|
flvUrl: string
|
|
|
|
|
|
player?: flvjs.Player
|
2025-12-03 13:25:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-04 12:46:15 +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////////////////////////////////////////////////////////////////////////////////'
|
2025-12-03 15:58:57 +08:00
|
|
|
|
|
2025-12-04 12:46:15 +08:00
|
|
|
|
export function useWebRTC(
|
|
|
|
|
|
userId: string,
|
|
|
|
|
|
onIncomingCall?: (senderUserId: string) => void,
|
|
|
|
|
|
getRoomId?: (receiverUserId?: string) => string
|
|
|
|
|
|
) {
|
2025-12-03 15:33:48 +08:00
|
|
|
|
const toastStore = useToastStore()
|
2025-12-03 15:58:57 +08:00
|
|
|
|
const chatStore = useChatStore()
|
2025-12-03 15:33:48 +08:00
|
|
|
|
|
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,
|
2025-12-04 12:46:15 +08:00
|
|
|
|
remoteMuted: false,
|
2025-12-03 13:25:02 +08:00
|
|
|
|
camOff: false,
|
2025-12-03 15:58:57 +08:00
|
|
|
|
remoteCamOff: false,
|
2025-12-03 15:33:48 +08:00
|
|
|
|
duration: 0,
|
|
|
|
|
|
startTime: null,
|
2025-12-15 14:13:56 +08:00
|
|
|
|
miniprogramStreams: [],
|
2025-12-03 13:25:02 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-03 15:58:57 +08:00
|
|
|
|
const isCaller = ref(false)
|
2025-12-03 15:33:48 +08:00
|
|
|
|
const localStream = shallowRef<MediaStream | null>(null)
|
|
|
|
|
|
const remoteStream = shallowRef<MediaStream | null>(null)
|
|
|
|
|
|
let durationTimer: number | null = null
|
2025-12-03 13:25:02 +08:00
|
|
|
|
let pc: RTCPeerConnection | null = null
|
2025-12-03 15:33:48 +08:00
|
|
|
|
const pendingCandidates: RTCIceCandidate[] = []
|
|
|
|
|
|
let currentReceiverUserId = ''
|
2025-12-04 12:46:15 +08:00
|
|
|
|
let currentRoomId = ''
|
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>('')
|
|
|
|
|
|
|
2025-12-03 18:02:26 +08:00
|
|
|
|
const audioIncoming = new Audio(RINGTONE_INCOMING_BASE64)
|
2025-12-03 15:58:57 +08:00
|
|
|
|
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-03 18:02:26 +08:00
|
|
|
|
|
2025-12-06 23:02:49 +08:00
|
|
|
|
// 【关键修复】如果是群聊信令,直接忽略!
|
|
|
|
|
|
// 通过判断是否存在 callRoomId 或 participantIds 来识别
|
|
|
|
|
|
if (content.callRoomId || content.participantIds) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const signal = message.call_status as any
|
|
|
|
|
|
const extra = message.extra ? (typeof message.extra === 'string' ? JSON.parse(message.extra) : message.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') {
|
|
|
|
|
|
if (call.active) 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 = `邀请你通话`
|
|
|
|
|
|
playRingtone('incoming')
|
|
|
|
|
|
if (onIncomingCall) onIncomingCall(message.sender_user_id)
|
|
|
|
|
|
|
|
|
|
|
|
} else if (signal === 'accepted') {
|
|
|
|
|
|
stopRingtone()
|
|
|
|
|
|
call.status = 'connected'
|
|
|
|
|
|
call.statusText = '通话中'
|
|
|
|
|
|
startCallTimer()
|
2025-12-15 14:13:56 +08:00
|
|
|
|
// 加入房间获取小程序用户的 FLV 流地址(RTMP 模式下也会启动 WebSocket 推流)
|
|
|
|
|
|
await joinCallRoomAndGetFlvUrls()
|
|
|
|
|
|
|
|
|
|
|
|
// WebRTC 模式:创建 Offer
|
|
|
|
|
|
if (!isRTMPMode()) {
|
|
|
|
|
|
if (!pc) await createPC()
|
|
|
|
|
|
const offer = await pc!.createOffer()
|
|
|
|
|
|
await pc!.setLocalDescription(offer)
|
|
|
|
|
|
sendSignal('offer', offer)
|
|
|
|
|
|
}
|
2025-12-06 23:02:49 +08:00
|
|
|
|
|
|
|
|
|
|
} else if (signal === 'offer') {
|
2025-12-15 14:13:56 +08:00
|
|
|
|
// WebRTC 模式才处理 offer
|
|
|
|
|
|
if (!isRTMPMode()) {
|
|
|
|
|
|
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-15 14:13:56 +08:00
|
|
|
|
// WebRTC 模式才处理 answer
|
|
|
|
|
|
if (!isRTMPMode() && pc) {
|
2025-12-06 23:02:49 +08:00
|
|
|
|
await pc.setRemoteDescription(content)
|
|
|
|
|
|
processPendingCandidates()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} else if (signal === 'candidate') {
|
2025-12-15 14:13:56 +08:00
|
|
|
|
// WebRTC 模式才处理 candidate
|
|
|
|
|
|
if (!isRTMPMode()) {
|
|
|
|
|
|
if (pc && pc.remoteDescription) await pc.addIceCandidate(content)
|
|
|
|
|
|
else pendingCandidates.push(content)
|
|
|
|
|
|
}
|
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-04 12:46:15 +08:00
|
|
|
|
}
|
2025-12-03 15:58:57 +08:00
|
|
|
|
}
|
2025-12-03 15:33:48 +08:00
|
|
|
|
|
2025-12-06 23:02:49 +08:00
|
|
|
|
// ... (其余辅助函数保持不变,为节省篇幅只展示关键变动,但你需要完整代码,下面补充完整辅助函数) ...
|
|
|
|
|
|
|
|
|
|
|
|
function playRingtone(type: 'incoming' | 'dialing') {
|
|
|
|
|
|
stopRingtone()
|
|
|
|
|
|
const playPromise = audioIncoming.play()
|
|
|
|
|
|
if (playPromise !== undefined) playPromise.catch(e => {})
|
2025-12-03 18:02:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-03 15:58:57 +08:00
|
|
|
|
function stopRingtone() {
|
|
|
|
|
|
audioIncoming.pause()
|
|
|
|
|
|
audioIncoming.currentTime = 0
|
|
|
|
|
|
}
|
2025-12-03 15:33:48 +08:00
|
|
|
|
|
2025-12-04 12:46:15 +08:00
|
|
|
|
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
|
|
|
|
}
|
2025-12-04 12:46:15 +08:00
|
|
|
|
if (targetUserId && userId) {
|
|
|
|
|
|
return [userId, targetUserId].sort().join('_')
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
2025-12-03 15:58:57 +08:00
|
|
|
|
}
|
2025-12-03 13:25:02 +08:00
|
|
|
|
|
2025-12-03 15:58:57 +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
|
2025-12-04 12:46:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- WebRTC ---
|
2025-12-03 15:58:57 +08:00
|
|
|
|
async function initMedia(videoEnabled: boolean): Promise<void> {
|
|
|
|
|
|
try {
|
2025-12-04 12:46:15 +08:00
|
|
|
|
if (localStream.value) {
|
|
|
|
|
|
localStream.value.getTracks().forEach(t => t.stop())
|
|
|
|
|
|
localStream.value = null
|
|
|
|
|
|
}
|
2025-12-03 15:58:57 +08:00
|
|
|
|
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 },
|
2025-12-03 15:33:48 +08:00
|
|
|
|
}
|
2025-12-03 15:58:57 +08:00
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
|
|
|
|
|
localStream.value = stream
|
2025-12-04 12:46:15 +08:00
|
|
|
|
} catch (error: any) {
|
2025-12-06 23:02:49 +08:00
|
|
|
|
throw new Error('无法获取设备权限')
|
2025-12-04 12:46:15 +08:00
|
|
|
|
}
|
2025-12-03 15:58:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 })
|
2025-12-04 12:46:15 +08:00
|
|
|
|
|
|
|
|
|
|
pc.oniceconnectionstatechange = () => {
|
2025-12-06 23:02:49 +08:00
|
|
|
|
if (pc?.iceConnectionState === 'failed') {
|
2025-12-04 12:46:15 +08:00
|
|
|
|
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 15:33:48 +08:00
|
|
|
|
}
|
2025-12-03 13:25:02 +08:00
|
|
|
|
|
|
|
|
|
|
function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) {
|
|
|
|
|
|
if (!call.id) return
|
2025-12-03 15:33:48 +08:00
|
|
|
|
const targetUserId = receiverUserId || currentReceiverUserId
|
2025-12-04 12:46:15 +08:00
|
|
|
|
const roomId = getSafeRoomId(targetUserId)
|
2025-12-06 23:02:49 +08:00
|
|
|
|
if (!roomId) return
|
2025-12-03 13:25:02 +08:00
|
|
|
|
const payload = {
|
|
|
|
|
|
sender_client_id: wsManager.getClientId() || '',
|
2025-12-03 15:33:48 +08:00
|
|
|
|
receiver_user_id: targetUserId,
|
2025-12-04 12:46:15 +08:00
|
|
|
|
room_id: roomId,
|
2025-12-03 15:58:57 +08:00
|
|
|
|
message_type: 6,
|
2025-12-03 13:25:02 +08:00
|
|
|
|
content: JSON.stringify(data || {}),
|
|
|
|
|
|
call_id: call.id,
|
|
|
|
|
|
call_status: status,
|
|
|
|
|
|
extra: JSON.stringify({ type: call.type }),
|
|
|
|
|
|
}
|
2025-12-03 15:58:57 +08:00
|
|
|
|
messageApi.sendMessage(payload).catch(console.error)
|
|
|
|
|
|
}
|
2025-12-03 13:25:02 +08:00
|
|
|
|
|
2025-12-03 18:02:26 +08:00
|
|
|
|
function sendSyncState(type: 'cam-toggle' | 'mic-toggle', value: boolean) {
|
2025-12-03 15:58:57 +08:00
|
|
|
|
if (call.status !== 'connected') return
|
2025-12-04 12:46:15 +08:00
|
|
|
|
const roomId = getSafeRoomId(currentReceiverUserId)
|
|
|
|
|
|
if (!roomId) return
|
2025-12-03 15:58:57 +08:00
|
|
|
|
const payload = {
|
|
|
|
|
|
sender_client_id: wsManager.getClientId() || '',
|
|
|
|
|
|
receiver_user_id: currentReceiverUserId,
|
2025-12-04 12:46:15 +08:00
|
|
|
|
room_id: roomId,
|
2025-12-03 18:02:26 +08:00
|
|
|
|
message_type: 6,
|
2025-12-03 15:58:57 +08:00
|
|
|
|
content: JSON.stringify({ action: type, value }),
|
|
|
|
|
|
call_id: call.id,
|
2025-12-03 18:02:26 +08:00
|
|
|
|
call_status: 'sync_state' as any,
|
2025-12-03 15:58:57 +08:00
|
|
|
|
extra: JSON.stringify({ type: call.type })
|
|
|
|
|
|
}
|
2025-12-03 13:25:02 +08:00
|
|
|
|
messageApi.sendMessage(payload).catch(console.error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-04 12:46:15 +08:00
|
|
|
|
async function startCall(type: 'audio' | 'video', receiverUserId: string, roomId?: string, contact?: Contact) {
|
|
|
|
|
|
if (roomId) currentRoomId = roomId
|
|
|
|
|
|
else if (contact?.room_id) currentRoomId = contact.room_id
|
|
|
|
|
|
else if (getRoomId) {
|
|
|
|
|
|
const found = getRoomId(receiverUserId)
|
|
|
|
|
|
if (found) currentRoomId = found
|
|
|
|
|
|
}
|
2025-12-03 15:33:48 +08:00
|
|
|
|
|
2025-12-04 12:46:15 +08:00
|
|
|
|
if (!currentRoomId) {
|
|
|
|
|
|
toastStore.error('无法建立通话连接:缺少房间信息')
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
2025-12-03 15:33:48 +08:00
|
|
|
|
|
2025-12-03 15:58:57 +08:00
|
|
|
|
isCaller.value = true
|
2025-12-03 13:25:02 +08:00
|
|
|
|
currentReceiverUserId = receiverUserId
|
|
|
|
|
|
call.type = type
|
|
|
|
|
|
call.id = Date.now().toString()
|
|
|
|
|
|
call.active = true
|
|
|
|
|
|
call.minimized = false
|
|
|
|
|
|
call.status = 'outgoing'
|
2025-12-03 15:33:48 +08:00
|
|
|
|
call.statusText = '正在呼叫...'
|
|
|
|
|
|
call.duration = 0
|
2025-12-03 15:58:57 +08:00
|
|
|
|
call.camOff = false
|
|
|
|
|
|
call.remoteCamOff = false
|
2025-12-04 12:46:15 +08:00
|
|
|
|
call.remoteMuted = false
|
2025-12-03 15:58:57 +08:00
|
|
|
|
remoteStream.value = null
|
2025-12-03 13:25:02 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await initMedia(type === 'video')
|
2025-12-15 14:13:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 根据模式选择不同的推流方式
|
|
|
|
|
|
if (isRTMPMode()) {
|
|
|
|
|
|
console.log('[WebRTC] 使用 RTMP 模式')
|
|
|
|
|
|
// RTMP 模式:加入房间后会自动开始 WebSocket 推流
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log('[WebRTC] 使用 WebRTC 模式')
|
|
|
|
|
|
await createPC()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-03 18:02:26 +08:00
|
|
|
|
playRingtone('dialing')
|
2025-12-03 13:25:02 +08:00
|
|
|
|
sendSignal('invite', undefined, receiverUserId)
|
2025-12-04 12:46:15 +08:00
|
|
|
|
return true
|
2025-12-03 13:25:02 +08:00
|
|
|
|
} catch (error: any) {
|
2025-12-03 15:33:48 +08:00
|
|
|
|
toastStore.error(error.message || '无法启动通话')
|
2025-12-03 15:58:57 +08:00
|
|
|
|
closeCall()
|
2025-12-04 12:46:15 +08:00
|
|
|
|
return false
|
2025-12-03 13:25:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function acceptCall(senderUserId?: string) {
|
2025-12-03 15:58:57 +08:00
|
|
|
|
isCaller.value = false
|
2025-12-03 18:02:26 +08:00
|
|
|
|
stopRingtone()
|
2025-12-03 15:33:48 +08:00
|
|
|
|
if (senderUserId) currentReceiverUserId = senderUserId
|
2025-12-03 13:25:02 +08:00
|
|
|
|
call.status = 'connected'
|
2025-12-03 15:33:48 +08:00
|
|
|
|
call.statusText = '正在连接...'
|
2025-12-03 15:58:57 +08:00
|
|
|
|
call.camOff = false
|
|
|
|
|
|
call.remoteCamOff = false
|
2025-12-03 13:25:02 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await initMedia(call.type === 'video')
|
2025-12-15 14:13:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 根据模式选择不同的推流方式
|
|
|
|
|
|
if (isRTMPMode()) {
|
|
|
|
|
|
console.log('[WebRTC] 接听:使用 RTMP 模式')
|
|
|
|
|
|
// RTMP 模式:加入房间后会自动开始 WebSocket 推流
|
|
|
|
|
|
await joinCallRoomAndGetFlvUrls()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log('[WebRTC] 接听:使用 WebRTC 模式')
|
|
|
|
|
|
await createPC()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-03 15:33:48 +08:00
|
|
|
|
sendSignal('accepted', undefined, currentReceiverUserId)
|
2025-12-03 13:25:02 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
endCall()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function endCall() {
|
2025-12-03 15:58:57 +08:00
|
|
|
|
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() {
|
2025-12-03 15:58:57 +08:00
|
|
|
|
stopRingtone()
|
2025-12-04 12:46:15 +08:00
|
|
|
|
currentRoomId = ''
|
2025-12-03 13:25:02 +08:00
|
|
|
|
call.active = false
|
|
|
|
|
|
call.status = 'idle'
|
|
|
|
|
|
call.statusText = ''
|
|
|
|
|
|
call.id = null
|
2025-12-03 15:33:48 +08:00
|
|
|
|
stopCallTimer()
|
2025-12-15 14:13:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 停止 WebSocket 推流 (RTMP 模式)
|
|
|
|
|
|
stopWebSocketPush()
|
|
|
|
|
|
|
2025-12-04 12:46:15 +08:00
|
|
|
|
if (pc) {
|
|
|
|
|
|
pc.close()
|
|
|
|
|
|
pc = null
|
|
|
|
|
|
}
|
|
|
|
|
|
if (localStream.value) {
|
2025-12-06 23:02:49 +08:00
|
|
|
|
localStream.value.getTracks().forEach(t => t.stop())
|
2025-12-04 12:46:15 +08:00
|
|
|
|
localStream.value = null
|
|
|
|
|
|
}
|
2025-12-03 15:33:48 +08:00
|
|
|
|
remoteStream.value = null
|
2025-12-15 14:13:56 +08:00
|
|
|
|
// 清理 FLV 播放器
|
|
|
|
|
|
destroyFlvPlayers()
|
|
|
|
|
|
call.miniprogramStreams = []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 加入通话房间并获取小程序用户的 FLV 流地址
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function joinCallRoomAndGetFlvUrls(): Promise<void> {
|
|
|
|
|
|
if (!currentRoomId) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await callApi.joinCallRoom({
|
|
|
|
|
|
room_id: currentRoomId,
|
|
|
|
|
|
user_id: userId,
|
|
|
|
|
|
platform: 'h5'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[WebRTC] 加入房间响应:', response)
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否有小程序用户的 FLV 流
|
|
|
|
|
|
if (response.flv_pull_urls && response.flv_pull_urls.length > 0) {
|
|
|
|
|
|
console.log('[WebRTC] 检测到小程序用户流:', response.flv_pull_urls)
|
|
|
|
|
|
call.miniprogramStreams = response.flv_pull_urls.map(p => ({
|
|
|
|
|
|
userId: p.user_id,
|
|
|
|
|
|
flvUrl: p.flv_url || ''
|
|
|
|
|
|
})).filter(s => s.flvUrl)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RTMP 模式:获取 WebSocket 推流地址并开始推流
|
|
|
|
|
|
if (isRTMPMode() && response.ws_push_url) {
|
|
|
|
|
|
wsPushUrl.value = response.ws_push_url
|
|
|
|
|
|
console.log('[WebRTC] RTMP模式,WebSocket推流地址:', response.ws_push_url)
|
|
|
|
|
|
// 开始 WebSocket 推流
|
|
|
|
|
|
if (localStream.value) {
|
|
|
|
|
|
startWebSocketPush(response.ws_push_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 录制
|
|
|
|
|
|
*/
|
|
|
|
|
|
function startMediaRecorder(): void {
|
|
|
|
|
|
if (!localStream.value || !wsPushSocket) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查浏览器支持的 MIME 类型
|
|
|
|
|
|
const mimeTypes = [
|
|
|
|
|
|
'video/webm;codecs=vp8,opus',
|
|
|
|
|
|
'video/webm;codecs=vp9,opus',
|
|
|
|
|
|
'video/webm;codecs=h264,opus',
|
|
|
|
|
|
'video/webm',
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
let selectedMimeType = ''
|
|
|
|
|
|
for (const mimeType of mimeTypes) {
|
|
|
|
|
|
if (MediaRecorder.isTypeSupported(mimeType)) {
|
|
|
|
|
|
selectedMimeType = mimeType
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!selectedMimeType) {
|
|
|
|
|
|
console.error('[WebRTC] 浏览器不支持 WebM 录制')
|
|
|
|
|
|
toastStore.error('您的浏览器不支持视频推流')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[WebRTC] 使用编码格式:', selectedMimeType)
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
mediaRecorder = new MediaRecorder(localStream.value, {
|
|
|
|
|
|
mimeType: selectedMimeType,
|
|
|
|
|
|
videoBitsPerSecond: 500000, // 500kbps
|
|
|
|
|
|
audioBitsPerSecond: 64000, // 64kbps
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
|
|
|
|
if (event.data.size > 0 && wsPushSocket?.readyState === WebSocket.OPEN) {
|
|
|
|
|
|
wsPushSocket.send(event.data)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
mediaRecorder.onerror = (error) => {
|
|
|
|
|
|
console.error('[WebRTC] MediaRecorder 错误:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
mediaRecorder.onstart = () => {
|
|
|
|
|
|
console.log('[WebRTC] MediaRecorder 开始录制')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
|
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 流')
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果已有播放器,先销毁
|
|
|
|
|
|
if (stream.player) {
|
|
|
|
|
|
stream.player.destroy()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) => {
|
|
|
|
|
|
console.error('[WebRTC] FLV 播放错误:', errorType, errorDetail)
|
|
|
|
|
|
if (errorType === flvjs.ErrorTypes.NETWORK_ERROR) {
|
|
|
|
|
|
toastStore.error('网络错误,无法播放小程序视频流')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 保存播放器引用
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-03 15:33:48 +08:00
|
|
|
|
async function processPendingCandidates() {
|
|
|
|
|
|
while (pendingCandidates.length > 0) {
|
2025-12-03 15:58:57 +08:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-04 12:46:15 +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
|
2025-12-04 12:46:15 +08:00
|
|
|
|
sendSyncState('mic-toggle', call.muted)
|
2025-12-03 15:58:57 +08:00
|
|
|
|
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted)
|
2025-12-03 13:25:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleCamera() {
|
|
|
|
|
|
call.camOff = !call.camOff
|
2025-12-04 12:46:15 +08:00
|
|
|
|
sendSyncState('cam-toggle', call.camOff)
|
2025-12-03 15:58:57 +08:00
|
|
|
|
if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff)
|
2025-12-03 13:25:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
2025-12-04 12:46:15 +08:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|