290 lines
7.2 KiB
TypeScript
290 lines
7.2 KiB
TypeScript
|
|
import { ref, reactive } from 'vue'
|
||
|
|
import * as systemApi from '@/api/modules/system'
|
||
|
|
import * as messageApi from '@/api/modules/message'
|
||
|
|
import { wsManager } from '@/api/websocket'
|
||
|
|
import type { ChatMessage } 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
|
||
|
|
camOff: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
export function useWebRTC(userId: string) {
|
||
|
|
const call = reactive<CallState>({
|
||
|
|
active: false,
|
||
|
|
minimized: false,
|
||
|
|
type: 'video',
|
||
|
|
status: 'idle',
|
||
|
|
statusText: '',
|
||
|
|
id: null,
|
||
|
|
muted: false,
|
||
|
|
camOff: false,
|
||
|
|
})
|
||
|
|
|
||
|
|
const localVideo = ref<HTMLVideoElement | null>(null)
|
||
|
|
const remoteVideo = ref<HTMLVideoElement | null>(null)
|
||
|
|
let pc: RTCPeerConnection | null = null
|
||
|
|
let localStream: MediaStream | null = null
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 初始化媒体流
|
||
|
|
*/
|
||
|
|
async function initMedia(videoEnabled: boolean): Promise<void> {
|
||
|
|
try {
|
||
|
|
const constraints = { video: videoEnabled, audio: true }
|
||
|
|
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||
|
|
localStream = stream
|
||
|
|
if (videoEnabled && localVideo.value) {
|
||
|
|
localVideo.value.srcObject = stream
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to get media:', error)
|
||
|
|
throw new Error('无法获取设备权限或设备不支持')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 获取ICE服务器配置
|
||
|
|
*/
|
||
|
|
async function getIceServers() {
|
||
|
|
try {
|
||
|
|
const servers = await systemApi.getIceServers(userId)
|
||
|
|
if (servers && servers.length > 0) {
|
||
|
|
return servers.map((s) => ({
|
||
|
|
urls: s.urls,
|
||
|
|
username: s.username,
|
||
|
|
credential: s.credential,
|
||
|
|
}))
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to fetch ICE servers:', error)
|
||
|
|
}
|
||
|
|
// Fallback: 使用默认STUN
|
||
|
|
return [{ urls: 'stun:stun.l.google.com:19302' }]
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 创建PeerConnection
|
||
|
|
*/
|
||
|
|
async function createPC(): Promise<void> {
|
||
|
|
const iceServers = await getIceServers()
|
||
|
|
iceServers.push({ urls: 'stun:stun.l.google.com:19302' })
|
||
|
|
|
||
|
|
pc = new RTCPeerConnection({ iceServers })
|
||
|
|
|
||
|
|
if (localStream) {
|
||
|
|
localStream.getTracks().forEach((track) => {
|
||
|
|
pc!.addTrack(track, localStream!)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pc.ontrack = (e) => {
|
||
|
|
if (remoteVideo.value) {
|
||
|
|
remoteVideo.value.srcObject = e.streams[0]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pc.onicecandidate = (e) => {
|
||
|
|
if (e.candidate && call.id) {
|
||
|
|
sendSignal('candidate', e.candidate)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let currentReceiverUserId = ''
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 发送信令
|
||
|
|
*/
|
||
|
|
function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) {
|
||
|
|
if (!call.id) return
|
||
|
|
|
||
|
|
const payload = {
|
||
|
|
sender_client_id: wsManager.getClientId() || '',
|
||
|
|
receiver_user_id: receiverUserId || currentReceiverUserId,
|
||
|
|
room_id: '',
|
||
|
|
message_type: 6,
|
||
|
|
content: JSON.stringify(data || {}),
|
||
|
|
call_id: call.id,
|
||
|
|
call_status: status,
|
||
|
|
extra: JSON.stringify({ type: call.type }),
|
||
|
|
}
|
||
|
|
|
||
|
|
messageApi.sendMessage(payload).catch(console.error)
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 开始通话
|
||
|
|
*/
|
||
|
|
async function startCall(type: 'audio' | 'video', receiverUserId: string) {
|
||
|
|
currentReceiverUserId = receiverUserId
|
||
|
|
call.type = type
|
||
|
|
call.id = Date.now().toString()
|
||
|
|
call.active = true
|
||
|
|
call.minimized = false
|
||
|
|
call.status = 'outgoing'
|
||
|
|
call.statusText = '等待对方接听...'
|
||
|
|
|
||
|
|
try {
|
||
|
|
await initMedia(type === 'video')
|
||
|
|
await createPC()
|
||
|
|
sendSignal('invite', undefined, receiverUserId)
|
||
|
|
} catch (error: any) {
|
||
|
|
alert(error.message || '无法启动通话')
|
||
|
|
endCall()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 接听通话
|
||
|
|
*/
|
||
|
|
async function acceptCall(senderUserId?: string) {
|
||
|
|
if (senderUserId) {
|
||
|
|
currentReceiverUserId = senderUserId
|
||
|
|
}
|
||
|
|
call.status = 'connected'
|
||
|
|
call.statusText = '连接中...'
|
||
|
|
|
||
|
|
try {
|
||
|
|
await initMedia(call.type === 'video')
|
||
|
|
await createPC()
|
||
|
|
|
||
|
|
const offer = await pc!.createOffer()
|
||
|
|
await pc!.setLocalDescription(offer)
|
||
|
|
sendSignal('accepted', undefined, senderUserId)
|
||
|
|
sendSignal('offer', offer, senderUserId)
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to accept call:', error)
|
||
|
|
endCall()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 结束通话
|
||
|
|
*/
|
||
|
|
function endCall() {
|
||
|
|
sendSignal('hangup')
|
||
|
|
closeCall()
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 关闭通话
|
||
|
|
*/
|
||
|
|
function closeCall() {
|
||
|
|
call.active = false
|
||
|
|
call.status = 'idle'
|
||
|
|
call.statusText = ''
|
||
|
|
call.id = null
|
||
|
|
|
||
|
|
if (pc) {
|
||
|
|
pc.close()
|
||
|
|
pc = null
|
||
|
|
}
|
||
|
|
|
||
|
|
if (localStream) {
|
||
|
|
localStream.getTracks().forEach((track) => track.stop())
|
||
|
|
localStream = null
|
||
|
|
}
|
||
|
|
|
||
|
|
if (localVideo.value) {
|
||
|
|
localVideo.value.srcObject = null
|
||
|
|
}
|
||
|
|
if (remoteVideo.value) {
|
||
|
|
remoteVideo.value.srcObject = null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 处理信令消息
|
||
|
|
*/
|
||
|
|
async function handleSignaling(message: ChatMessage) {
|
||
|
|
const signal = message.call_status as CallStatus
|
||
|
|
const extra = typeof message.extra === 'string' ? JSON.parse(message.extra || '{}') : message.extra
|
||
|
|
|
||
|
|
let callType = 'video'
|
||
|
|
if (extra && extra.type) {
|
||
|
|
callType = extra.type
|
||
|
|
}
|
||
|
|
|
||
|
|
if (signal === 'invite') {
|
||
|
|
currentReceiverUserId = message.sender_user_id
|
||
|
|
call.id = message.call_id || Date.now().toString()
|
||
|
|
call.type = callType as 'audio' | 'video'
|
||
|
|
call.active = true
|
||
|
|
call.minimized = false
|
||
|
|
call.status = 'incoming'
|
||
|
|
call.statusText = `对方邀请您进行${callType === 'video' ? '视频' : '语音'}通话...`
|
||
|
|
} else if (signal === 'accepted') {
|
||
|
|
call.status = 'connected'
|
||
|
|
call.statusText = '通话中'
|
||
|
|
const offer = await pc!.createOffer()
|
||
|
|
await pc!.setLocalDescription(offer)
|
||
|
|
sendSignal('offer', offer, message.sender_user_id)
|
||
|
|
} else if (signal === 'offer') {
|
||
|
|
const desc = JSON.parse(message.content)
|
||
|
|
if (!pc) {
|
||
|
|
await initMedia(call.type === 'video')
|
||
|
|
await createPC()
|
||
|
|
}
|
||
|
|
await pc!.setRemoteDescription(desc)
|
||
|
|
const answer = await pc!.createAnswer()
|
||
|
|
await pc!.setLocalDescription(answer)
|
||
|
|
sendSignal('answer', answer, message.sender_user_id)
|
||
|
|
call.status = 'connected'
|
||
|
|
call.statusText = '通话中'
|
||
|
|
} else if (signal === 'answer') {
|
||
|
|
await pc!.setRemoteDescription(JSON.parse(message.content))
|
||
|
|
} else if (signal === 'candidate') {
|
||
|
|
await pc!.addIceCandidate(JSON.parse(message.content))
|
||
|
|
} else if (['hangup', 'ended', 'answered_elsewhere'].includes(signal)) {
|
||
|
|
closeCall()
|
||
|
|
if (signal === 'answered_elsewhere') {
|
||
|
|
alert('已在其他设备接听')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 切换静音
|
||
|
|
*/
|
||
|
|
function toggleMute() {
|
||
|
|
call.muted = !call.muted
|
||
|
|
if (localStream) {
|
||
|
|
localStream.getAudioTracks()[0].enabled = !call.muted
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 切换摄像头
|
||
|
|
*/
|
||
|
|
function toggleCamera() {
|
||
|
|
call.camOff = !call.camOff
|
||
|
|
if (localStream) {
|
||
|
|
const videoTrack = localStream.getVideoTracks()[0]
|
||
|
|
if (videoTrack) {
|
||
|
|
videoTrack.enabled = !call.camOff
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
call,
|
||
|
|
localVideo,
|
||
|
|
remoteVideo,
|
||
|
|
startCall,
|
||
|
|
acceptCall,
|
||
|
|
endCall,
|
||
|
|
handleSignaling,
|
||
|
|
toggleMute,
|
||
|
|
toggleCamera,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|