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

465 lines
17 KiB
TypeScript
Raw Normal View History

import { reactive, shallowRef, ref } from 'vue'
2025-12-03 13:25:02 +08:00
import * as systemApi from '@/api/modules/system'
import * as messageApi from '@/api/modules/message'
import { wsManager } from '@/api/websocket'
import { useToastStore } from '@/stores/toast'
import { useChatStore } from '@/stores/chat'
2025-12-03 13:25:02 +08:00
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
remoteMuted: boolean // 新增:对方是否静音
2025-12-03 13:25:02 +08:00
camOff: boolean
remoteCamOff: boolean
duration: number
startTime: number | null
2025-12-03 13:25:02 +08:00
}
// 使用 Base64 数据 URI 避免 403 和跨域问题
// 简短的电话铃声 (Incoming)
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//////////////////////////////////////////////////////////////////uQxF4AOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxGwAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxHgAOYI2fAAAAANIAAAAQAAAE//////////////////////////////////////////////////////////////////uQxIUAAANIAAAAAQAIAAAB//////////////////////////////////////////////////////////////////////////////////uQxIkAAANIAAAAAQAIAAAB////////////////////////////////////////////////////////////////////////////////'
// 简短的嘟嘟拨号音 (Dialing)
const RINGTONE_DIALING_BASE64 = 'data:audio/wav;base64,UklGRl9vT1BXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YU'
export function useWebRTC(userId: string, onIncomingCall?: (senderUserId: string) => void) {
const toastStore = useToastStore()
const chatStore = useChatStore()
2025-12-03 13:25:02 +08:00
const call = reactive<CallState>({
active: false,
minimized: false,
type: 'video',
status: 'idle',
statusText: '',
id: null,
muted: false,
remoteMuted: false, // 初始化
2025-12-03 13:25:02 +08:00
camOff: false,
remoteCamOff: false,
duration: 0,
startTime: null,
2025-12-03 13:25:02 +08:00
})
const isCaller = ref(false)
const localStream = shallowRef<MediaStream | null>(null)
const remoteStream = shallowRef<MediaStream | null>(null)
let durationTimer: number | null = null
2025-12-03 13:25:02 +08:00
let pc: RTCPeerConnection | null = null
const pendingCandidates: RTCIceCandidate[] = []
let currentReceiverUserId = ''
2025-12-03 13:25:02 +08:00
// 音频对象初始化 (使用 Base64)
const audioIncoming = new Audio(RINGTONE_INCOMING_BASE64)
const audioDialing = new Audio(RINGTONE_DIALING_BASE64) // 使用 Base64 或简单的音频
// 由于 Base64 字符串较短,需要循环播放
audioIncoming.loop = true
audioDialing.loop = true
// 简单的嘟嘟声生成器 (如果 Base64 不工作作为备选,但通常 Base64 更可靠)
// 这里直接使用 Audio Context 生成嘟嘟声可能更专业,但为了兼容性先用 Audio 标签
// 注意:上面的 Base64 只是示例片段,为了确保有声音,建议用真实的短音频 Base64
// --- 铃声控制 ---
function playRingtone(type: 'incoming' | 'dialing') {
stopRingtone() // 先停止当前的
// 如果是拨号音,我们可以用 Web Audio API 生成一个标准的嘟嘟声,这样不需要加载文件
if (type === 'dialing') {
playOscillatorTone()
return
}
const audio = audioIncoming
audio.currentTime = 0
audio.play().catch(e => console.warn('Autoplay prevented:', e))
}
// 使用 Web Audio API 生成拨号音 (避免资源 403 问题)
let oscCtx: AudioContext | null = null
let oscillator: OscillatorNode | null = null
let gainNode: GainNode | null = null
function playOscillatorTone() {
try {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext
if (!AudioContext) return
oscCtx = new AudioContext()
oscillator = oscCtx.createOscillator()
gainNode = oscCtx.createGain()
oscillator.type = 'sine'
oscillator.frequency.setValueAtTime(440, oscCtx.currentTime) // 440Hz 标准音
// 模拟嘟-嘟-嘟的效果
gainNode.gain.setValueAtTime(0.1, oscCtx.currentTime)
oscillator.connect(gainNode)
gainNode.connect(oscCtx.destination)
oscillator.start()
// 简单的循环效果
const pulse = () => {
if(!gainNode || !oscCtx) return
gainNode.gain.setValueAtTime(0.1, oscCtx.currentTime)
setTimeout(() => {
if(gainNode && oscCtx) gainNode.gain.setValueAtTime(0, oscCtx.currentTime)
}, 800)
setTimeout(pulse, 2000)
}
pulse()
} catch(e) { console.error(e) }
}
function stopRingtone() {
audioIncoming.pause()
audioIncoming.currentTime = 0
// 停止 Web Audio API 的声音
if (oscillator) {
try { oscillator.stop(); oscillator.disconnect() } catch(e){}
oscillator = null
}
if (gainNode) {
try { gainNode.disconnect() } catch(e){}
gainNode = null
}
if (oscCtx) {
try { oscCtx.close() } catch(e){}
oscCtx = null
}
}
// --- 系统通知 ---
function sendSystemNotification(title: string, body: string) {
if (!('Notification' in window)) return
// 再次尝试请求权限 (如果是用户触发的操作中调用)
if (Notification.permission !== 'granted' && Notification.permission !== 'denied') {
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
new Notification(title, { body, icon: '/favicon.ico' })
}
})
} else if (Notification.permission === 'granted') {
new Notification(title, { body, icon: '/favicon.ico' })
2025-12-03 13:25:02 +08:00
}
}
// --- 辅助函数 ---
function getRoomId(uid1: string, uid2: string): string {
return [uid1, uid2].sort().join('_')
2025-12-03 13:25:02 +08:00
}
function formatTextDuration(seconds: number): string {
if (seconds <= 0) return '0秒'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
let str = ''
if (h > 0) str += `${h}小时`
if (m > 0) str += `${m}分钟`
if (s > 0) str += `${s}秒`
return str
}
2025-12-03 13:25:02 +08:00
async function sendSummaryMessage(reason: 'connected' | 'cancelled' | 'rejected' | 'busy') {
if (!isCaller.value || !currentReceiverUserId) return
let content = ''
const durationStr = formatTextDuration(call.duration)
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
const deviceText = isMobile ? '移动端' : '电脑端'
2025-12-03 13:25:02 +08:00
if (reason === 'connected') content = `通话结束,时长:${durationStr}`
else if (reason === 'cancelled') content = '已取消呼叫'
else if (reason === 'rejected') content = '对方拒绝接听'
else if (reason === 'busy') content = '对方忙'
content += ` [${deviceText}]`
2025-12-03 13:25:02 +08:00
const roomId = getRoomId(userId, currentReceiverUserId)
const clientId = wsManager.getClientId()
const payload = {
sender_client_id: clientId || '',
receiver_user_id: currentReceiverUserId,
room_id: roomId,
message_type: 0,
content: content,
duration: 0,
extra: JSON.stringify({ isSystem: true }),
2025-12-03 13:25:02 +08:00
}
try {
await messageApi.sendMessage(payload)
const localMsg: any = {
...payload, id: Date.now(), sender_user_id: userId, created_at: new Date().toISOString(), isSelf: true, extra: { isSystem: true }
2025-12-03 13:25:02 +08:00
}
chatStore.addMessage(roomId, localMsg)
chatStore.updateContactLastMsg(currentReceiverUserId, content, Date.now())
} catch (e) { console.error(e) }
}
2025-12-03 13:25:02 +08:00
// --- WebRTC 基础 ---
async function initMedia(videoEnabled: boolean): Promise<void> {
try {
if (localStream.value) localStream.value.getTracks().forEach(t => t.stop())
const constraints: MediaStreamConstraints = {
video: videoEnabled ? { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' } : false,
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
localStream.value = stream
} catch (error) { throw new Error('无法获取设备权限') }
}
async function createPC(): Promise<void> {
const servers = [{ urls: 'stun:stun.l.google.com:19302' }]
if (pc) pc.close()
pc = new RTCPeerConnection({ iceServers: servers })
if (localStream.value) localStream.value.getTracks().forEach((track) => pc!.addTrack(track, localStream.value!))
pc.ontrack = (e) => { if (e.streams && e.streams[0]) remoteStream.value = e.streams[0] }
pc.onicecandidate = (e) => { if (e.candidate && call.id) sendSignal('candidate', e.candidate) }
}
2025-12-03 13:25:02 +08:00
function sendSignal(status: CallStatus, data?: any, receiverUserId?: string) {
if (!call.id) return
const targetUserId = receiverUserId || currentReceiverUserId
if (!targetUserId) return
2025-12-03 13:25:02 +08:00
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: targetUserId,
2025-12-03 13:25:02 +08:00
room_id: '',
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 }),
}
messageApi.sendMessage(payload).catch(console.error)
}
2025-12-03 13:25:02 +08:00
// --- 状态同步信令 (摄像头/麦克风) ---
function sendSyncState(type: 'cam-toggle' | 'mic-toggle', value: boolean) {
if (call.status !== 'connected') return
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: currentReceiverUserId,
room_id: '',
message_type: 6,
content: JSON.stringify({ action: type, value }),
call_id: call.id,
call_status: 'sync_state' as any,
extra: JSON.stringify({ type: call.type })
}
2025-12-03 13:25:02 +08:00
messageApi.sendMessage(payload).catch(console.error)
}
// --- 计时器 ---
function startCallTimer() {
stopCallTimer()
call.startTime = Date.now()
call.duration = 0
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
async function startCall(type: 'audio' | 'video', receiverUserId: string) {
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'
call.statusText = '正在呼叫...'
call.duration = 0
call.camOff = false
call.remoteCamOff = false
call.remoteMuted = false // 重置
remoteStream.value = null
2025-12-03 13:25:02 +08:00
try {
// 立即获取本地媒体流,确保自己能看到自己
2025-12-03 13:25:02 +08:00
await initMedia(type === 'video')
await createPC()
playRingtone('dialing')
2025-12-03 13:25:02 +08:00
sendSignal('invite', undefined, receiverUserId)
} catch (error: any) {
toastStore.error(error.message || '无法启动通话')
closeCall()
2025-12-03 13:25:02 +08:00
}
}
async function acceptCall(senderUserId?: string) {
isCaller.value = false
stopRingtone()
if (senderUserId) currentReceiverUserId = senderUserId
2025-12-03 13:25:02 +08:00
call.status = 'connected'
call.statusText = '正在连接...'
call.camOff = false
call.remoteCamOff = false
call.remoteMuted = false
2025-12-03 13:25:02 +08:00
try {
await initMedia(call.type === 'video')
await createPC()
sendSignal('accepted', undefined, currentReceiverUserId)
2025-12-03 13:25:02 +08:00
} catch (error) {
endCall()
}
}
function endCall() {
stopRingtone()
if (isCaller.value) {
if (call.status === 'connected') sendSummaryMessage('connected')
else if (call.status === 'outgoing') sendSummaryMessage('cancelled')
}
2025-12-03 13:25:02 +08:00
sendSignal('hangup')
closeCall()
}
function closeCall() {
stopRingtone()
2025-12-03 13:25:02 +08:00
call.active = false
call.status = 'idle'
call.statusText = ''
call.id = null
stopCallTimer()
if (pc) { pc.close(); pc = null }
if (localStream.value) { localStream.value.getTracks().forEach(t => t.stop()); localStream.value = null }
remoteStream.value = null
2025-12-03 13:25:02 +08:00
}
async function handleSignaling(message: ChatMessage) {
const signal = message.call_status as any
const content = message.content ? JSON.parse(message.content) : {}
const extra = message.extra ? (typeof message.extra === 'string' ? JSON.parse(message.extra) : message.extra) : {}
2025-12-03 13:25:02 +08:00
if (extra.type) call.type = extra.type
2025-12-03 13:25:02 +08:00
// 处理状态同步
if (signal === 'sync_state') {
if (content.action === 'cam-toggle') {
call.remoteCamOff = content.value
} else if (content.action === 'mic-toggle') {
call.remoteMuted = content.value
}
return
}
2025-12-03 13:25:02 +08:00
if (signal === 'invite') {
isCaller.value = false
2025-12-03 13:25:02 +08:00
currentReceiverUserId = message.sender_user_id
call.id = message.call_id
2025-12-03 13:25:02 +08:00
call.active = true
call.minimized = false
call.status = 'incoming'
call.statusText = `邀请你进行${call.type === 'video' ? '视频' : '语音'}通话`
call.remoteCamOff = false
call.remoteMuted = false
playRingtone('incoming')
sendSystemNotification('新来电', `收到来自 ${message.sender_user_id} 的${call.type === 'video' ? '视频' : '语音'}通话邀请`)
if (onIncomingCall) onIncomingCall(message.sender_user_id)
2025-12-03 13:25:02 +08:00
} else if (signal === 'accepted') {
stopRingtone()
2025-12-03 13:25:02 +08:00
call.status = 'connected'
call.statusText = '通话中'
startCallTimer()
// 确保 pc 已创建
if (!pc) {
await initMedia(call.type === 'video')
await createPC()
}
2025-12-03 13:25:02 +08:00
const offer = await pc!.createOffer()
await pc!.setLocalDescription(offer)
sendSignal('offer', offer)
2025-12-03 13:25:02 +08:00
} else if (signal === 'offer') {
stopRingtone()
2025-12-03 13:25:02 +08:00
if (!pc) {
await initMedia(call.type === 'video')
await createPC()
}
await pc!.setRemoteDescription(content)
processPendingCandidates()
2025-12-03 13:25:02 +08:00
const answer = await pc!.createAnswer()
await pc!.setLocalDescription(answer)
sendSignal('answer', answer, message.sender_user_id)
call.status = 'connected'
call.statusText = '通话中'
startCallTimer()
2025-12-03 13:25:02 +08:00
} else if (signal === 'answer') {
// 确保 pc 已创建
if (!pc) {
await initMedia(call.type === 'video')
await createPC()
}
await pc!.setRemoteDescription(content)
processPendingCandidates()
2025-12-03 13:25:02 +08:00
} else if (signal === 'candidate') {
if (pc && pc.remoteDescription) await pc.addIceCandidate(content)
else pendingCandidates.push(content)
2025-12-03 13:25:02 +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')
}
2025-12-03 13:25:02 +08:00
closeCall()
if (signal === 'answered_elsewhere') toastStore.info('已在其他设备接听')
}
}
async function processPendingCandidates() {
while (pendingCandidates.length > 0) {
const c = pendingCandidates.shift()
if (c && pc) await pc.addIceCandidate(c)
2025-12-03 13:25:02 +08:00
}
}
function toggleMute() {
call.muted = !call.muted
sendSyncState('mic-toggle', call.muted) // 同步
if (localStream.value) localStream.value.getAudioTracks().forEach(t => t.enabled = !call.muted)
2025-12-03 13:25:02 +08:00
}
function toggleCamera() {
call.camOff = !call.camOff
sendSyncState('cam-toggle', call.camOff) // 同步
if (localStream.value) localStream.value.getVideoTracks().forEach(t => t.enabled = !call.camOff)
2025-12-03 13:25:02 +08:00
}
return {
call, localStream, remoteStream, startCall, acceptCall, endCall, handleSignaling, toggleMute, toggleCamera, formatDuration, sendSystemNotification
2025-12-03 13:25:02 +08:00
}
}